mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-14 02:25:50 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e2270ec25 | ||
|
|
8093ecf4c8 | ||
|
|
6840371774 | ||
|
|
d67fc47a61 | ||
|
|
d0f3fad133 | ||
|
|
624a13ecc6 | ||
|
|
492e700d1f | ||
|
|
b5ee5b871e | ||
|
|
6fb21c9ec0 | ||
|
|
41260a55f6 | ||
|
|
3031ab5d97 | ||
|
|
acb44a68a9 | ||
|
|
6927de6003 | ||
|
|
613a286cde | ||
|
|
55ac46f7a9 | ||
|
|
3f8035deb7 | ||
|
|
a4c8d31cb8 | ||
|
|
6bac506842 | ||
|
|
2c031d9d63 | ||
|
|
d69f45ee83 | ||
|
|
3aaf80b33b | ||
|
|
85f04b54fd | ||
|
|
6f93f87446 | ||
|
|
73949daa98 | ||
|
|
603a1221a7 | ||
|
|
f11cecc697 |
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.38.2
|
||||
uses: github/codeql-action/init@v4.38.3
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: go
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4.38.2
|
||||
uses: github/codeql-action/autobuild@v4.38.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -49,4 +49,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.38.2
|
||||
uses: github/codeql-action/analyze@v4.38.3
|
||||
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
output: trivy-results.sarif
|
||||
exit-code: '0'
|
||||
- name: Upload Trivy scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v4.38.2
|
||||
uses: github/codeql-action/upload-sarif@v4.38.3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4.38.2
|
||||
uses: github/codeql-action/upload-sarif@v4.38.3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-${{ matrix.variant }}
|
||||
|
||||
@@ -95,6 +95,41 @@ jobs:
|
||||
grep -A 5 "^ lifecycle:" /tmp/s3-rollout.yaml | grep -q -- "- sleep"
|
||||
echo "S3 rollout settings render correctly"
|
||||
|
||||
echo "=== Testing the S3 config checksum is stable across renders ==="
|
||||
# s3-secret.yaml keeps its generated keys with lookup. Hashing the rendered
|
||||
# Secret drew new keys for the checksum whenever lookup found nothing (on
|
||||
# install), so the first upgrade changed the checksum and restarted the
|
||||
# filer and S3 pods although nothing had changed.
|
||||
s3_checksum() { # <template> <helm args...>: the checksum/s3config of one workload
|
||||
local template=$1; shift
|
||||
local sum
|
||||
sum=$(helm template test $CHART_DIR --show-only "$template" "$@" \
|
||||
| grep 'checksum/s3config:' | awk '{print $2}')
|
||||
test -n "$sum" || { echo "no checksum/s3config in $template" >&2; return 1; }
|
||||
echo "$sum"
|
||||
}
|
||||
s3_auth_args=(--set s3.enabled=true --set s3.enableAuth=true)
|
||||
s3_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")
|
||||
filer_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}")
|
||||
test "$s3_sum" = "$filer_sum" # one Secret, one checksum
|
||||
test "$s3_sum" = "$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")"
|
||||
s3_creds_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}" \
|
||||
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
|
||||
test "$s3_sum" != "$s3_creds_sum"
|
||||
filer_audit_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
|
||||
--set-json 'filer.s3.auditLogConfig={"fluent_port":24224}')
|
||||
test "$filer_sum" != "$filer_audit_sum"
|
||||
# Settings the Secret does not read must not restart the S3 and filer pods.
|
||||
test "$filer_sum" = "$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
|
||||
--set s3.replicas=3 --set s3.resources.limits.cpu=1)"
|
||||
all_in_one_args=(--set allInOne.enabled=true --set allInOne.s3.enabled=true --set allInOne.s3.enableAuth=true)
|
||||
aio_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")
|
||||
test "$aio_sum" = "$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")"
|
||||
aio_creds_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}" \
|
||||
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
|
||||
test "$aio_sum" != "$aio_creds_sum"
|
||||
echo "S3 config checksum is stable and follows only what the S3 Secret reads"
|
||||
|
||||
echo "=== Testing S3 credentials from an existing secret ==="
|
||||
credential_args=(
|
||||
--set s3.credentials.admin.existingSecret=minio-root
|
||||
|
||||
@@ -43,7 +43,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
|
||||
annotations:
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml $podAnnotations | nindent 8 }}
|
||||
|
||||
@@ -44,7 +44,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml $podAnnotations | nindent 8 }}
|
||||
|
||||
@@ -692,3 +692,28 @@ Usage: {{ include "seaweedfs.service.loadBalancerFields" .Values.s3.service }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Checksum that restarts the S3-serving pods when the generated S3 config changes. It hashes the
|
||||
values s3-secret.yaml is built from, not the rendered Secret: that template keeps its generated keys
|
||||
with lookup, so rendering it again for a checksum draws new random keys whenever lookup finds
|
||||
nothing (on install), and the first upgrade then changed the checksum and restarted the pods
|
||||
although nothing had changed. Only the values that template reads are hashed, so scaling the S3
|
||||
gateway or changing its resources does not restart the filer. Keep this list in step with
|
||||
s3-secret.yaml.
|
||||
Keys the chart generated are not part of it. Deleting the Secret to rotate them regenerates them on
|
||||
the next upgrade without restarting the pods; restart them afterwards (kubectl rollout restart).
|
||||
Folding the stored keys in would bring the first-upgrade restart back, since lookup finds nothing
|
||||
on install.
|
||||
*/}}
|
||||
{{- define "seaweedfs.s3.configChecksum" -}}
|
||||
{{- dict
|
||||
"secretName" (include "seaweedfs.fullname" .)
|
||||
"enabled" (list .Values.s3.enabled .Values.filer.s3.enabled .Values.allInOne.enabled .Values.allInOne.s3.enabled)
|
||||
"enableAuth" (list .Values.s3.enableAuth .Values.filer.s3.enableAuth .Values.allInOne.s3.enableAuth)
|
||||
"existingConfigSecret" (list .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret .Values.allInOne.s3.existingConfigSecret)
|
||||
"credentials" .Values.s3.credentials
|
||||
"legacySecret" (list .Values.s3.reuseLegacySecret .Values.s3.legacySecretName)
|
||||
"auditLogConfig" (list .Values.s3.auditLogConfig .Values.filer.s3.auditLogConfig)
|
||||
| toJson | sha256sum -}}
|
||||
{{- end -}}
|
||||
|
||||
+1050
-1059
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Generated
+15
-8
@@ -234,9 +234,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -244,14 +244,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2677,6 +2678,12 @@ dependencies = [
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -3389,9 +3396,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3436,9 +3443,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -986,7 +986,12 @@ impl VolumeServer for VolumeGrpcService {
|
||||
let original_cookie = n.cookie;
|
||||
if !is_ec_volume {
|
||||
let store = self.state.store.read().unwrap();
|
||||
match store.read_volume_needle(file_id.volume_id, &mut n) {
|
||||
let mut read_option = crate::server::handlers::delete_read_option();
|
||||
match store.read_volume_needle_with_option(
|
||||
file_id.volume_id,
|
||||
&mut n,
|
||||
&mut read_option,
|
||||
) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
results.push(volume_server_pb::DeleteResult {
|
||||
@@ -10997,6 +11002,50 @@ mod tests {
|
||||
(r.status, r.error.clone())
|
||||
}
|
||||
|
||||
/// A batch delete only needs the needle's cookie, which is stored in the
|
||||
/// header, so it must not read the payload.
|
||||
#[tokio::test]
|
||||
async fn batch_delete_reads_needle_meta_only() {
|
||||
use crate::storage::volume::needle_read_hook;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const ID: u64 = 0x6e7a_0b01;
|
||||
let (service, _tmp) = make_local_service_with_volume("", None);
|
||||
let data = vec![7u8; 512 * 1024];
|
||||
let mut n = Needle {
|
||||
id: NeedleId(ID),
|
||||
cookie: Cookie(0x3344),
|
||||
data: data.clone(),
|
||||
data_size: data.len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
service
|
||||
.state
|
||||
.store
|
||||
.write()
|
||||
.unwrap()
|
||||
.write_volume_needle(VolumeId(1), &mut n, false)
|
||||
.unwrap();
|
||||
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let _hook = needle_read_hook::register(NeedleId(ID), {
|
||||
let reads = reads.clone();
|
||||
move |len| reads.lock().unwrap().push(len)
|
||||
});
|
||||
|
||||
let (status, _) = batch_delete_1(&service, ID, 0x3345, false).await;
|
||||
assert_eq!(status, 400);
|
||||
let (status, error) = batch_delete_1(&service, ID, 0x3344, false).await;
|
||||
assert_eq!(status, 202, "{error}");
|
||||
|
||||
let reads = reads.lock().unwrap().clone();
|
||||
assert!(
|
||||
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
|
||||
"a batch delete must not read the needle's payload ({} bytes): {reads:?}",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
|
||||
const GO_EC_NEEDLE_NOT_FOUND: &str =
|
||||
"locate in local ec volume: FindNeedleFromEcx: needle not found";
|
||||
|
||||
|
||||
@@ -161,9 +161,10 @@ impl SourceReadRequest {
|
||||
|
||||
/// HEAD, a range, or a stream.
|
||||
fn served_from_source(&self, n: &Needle) -> bool {
|
||||
// HEAD is meta-only unless the payload is needed to expand a manifest.
|
||||
if self.is_head {
|
||||
return !(n.is_chunk_manifest() && !self.bypass_cm);
|
||||
// HEAD is meta-only unless the payload is needed to expand a
|
||||
// manifest or to answer an image transform's length.
|
||||
return !self.has_image_ops && !(n.is_chunk_manifest() && !self.bypass_cm);
|
||||
}
|
||||
self.direct(n) && (self.has_range || n.data_size > STREAMING_THRESHOLD)
|
||||
}
|
||||
@@ -171,8 +172,10 @@ impl SourceReadRequest {
|
||||
/// Meta first only if the needle could be served from source; its data
|
||||
/// size is below its index size.
|
||||
fn try_meta_first(&self, size: Size) -> bool {
|
||||
self.is_head
|
||||
|| (!self.has_image_ops && (self.has_range || size.0 > STREAMING_THRESHOLD as i32))
|
||||
if self.has_image_ops {
|
||||
return false;
|
||||
}
|
||||
self.is_head || self.has_range || size.0 > STREAMING_THRESHOLD as i32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3058,6 +3061,20 @@ struct DeleteResult {
|
||||
size: i64,
|
||||
}
|
||||
|
||||
/// Read option for deletes: read only the needle's header and trailer.
|
||||
///
|
||||
/// A delete needs to know that the needle exists, what its cookie is, and
|
||||
/// whether it is a chunk manifest. None of that requires the payload. If the
|
||||
/// needle turns out to be a chunk manifest, the read loads its data as well,
|
||||
/// because the delete needs the manifest to delete the chunks.
|
||||
pub(crate) fn delete_read_option() -> ReadOption {
|
||||
ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_handler(
|
||||
State(state): State<Arc<VolumeServerState>>,
|
||||
request: Request<Body>,
|
||||
@@ -3226,7 +3243,7 @@ pub async fn delete_handler(
|
||||
let original_cookie = cookie;
|
||||
{
|
||||
let store = state.store.read().unwrap();
|
||||
match store.read_volume_needle(vid, &mut n) {
|
||||
match store.read_volume_needle_with_option(vid, &mut n, &mut delete_read_option()) {
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
let result = DeleteResult { size: 0 };
|
||||
@@ -5523,6 +5540,17 @@ mod tests {
|
||||
(status, headers, body)
|
||||
}
|
||||
|
||||
/// The public router does not accept DELETE requests, so this calls the
|
||||
/// handler directly, the same way the admin router does.
|
||||
async fn send_delete(state: &Arc<VolumeServerState>, path: &str) -> StatusCode {
|
||||
let request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
delete_handler(State(state.clone()), request).await.status()
|
||||
}
|
||||
|
||||
/// A GET whose needle read is parked must not hold the store lock: a
|
||||
/// writer, here a real append to the same volume, must get through.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -5627,6 +5655,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A delete needs the needle's cookie and its chunk-manifest flag. Both
|
||||
/// are stored in the header and trailer, so the delete must not read the
|
||||
/// payload.
|
||||
#[tokio::test]
|
||||
async fn test_delete_reads_needle_meta_only() {
|
||||
use crate::storage::volume::needle_read_hook;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const ID: u64 = 0x6e7a_0a01;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let state = volume_test_state(&tmp);
|
||||
let data: Vec<u8> = (0..512 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
let path = put_test_needle(&state, ID, &data);
|
||||
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let _hook = needle_read_hook::register(NeedleId(ID), {
|
||||
let reads = reads.clone();
|
||||
move |len| reads.lock().unwrap().push(len)
|
||||
});
|
||||
|
||||
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE + 1);
|
||||
assert_eq!(
|
||||
send_delete(&state, &wrong_cookie).await,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(send_delete(&state, &path).await, StatusCode::ACCEPTED);
|
||||
|
||||
let reads = reads.lock().unwrap().clone();
|
||||
assert!(
|
||||
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
|
||||
"a delete must not read the needle's payload ({} bytes): {reads:?}",
|
||||
data.len()
|
||||
);
|
||||
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// HEAD and ranged reads keep answering from the needle meta and the
|
||||
/// data file, and a missing or wrong-cookie needle is still a 404.
|
||||
#[tokio::test]
|
||||
@@ -6105,6 +6170,29 @@ mod tests {
|
||||
assert_eq!(taken(&reads), vec![1]);
|
||||
}
|
||||
|
||||
/// A HEAD asking for an image transform needs the payload too: a
|
||||
/// meta-only read could not produce the transformed length.
|
||||
#[tokio::test]
|
||||
async fn test_head_with_image_op_reads_full() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let state = volume_test_state(&tmp);
|
||||
let png = solid_png(8, 8);
|
||||
let stored = put_test_needle(&state, 0x6e7a_0a30, &png);
|
||||
let crop = format!(
|
||||
"{}.png?crop_x1=0&crop_y1=0&crop_x2=2&crop_y2=2",
|
||||
stored
|
||||
);
|
||||
|
||||
let (get_status, _, cropped) = send_read(&state, Method::GET, &crop, None).await;
|
||||
assert_eq!(get_status, StatusCode::OK);
|
||||
assert_ne!(cropped.len(), png.len(), "the crop must change the length");
|
||||
|
||||
let (status, headers, body) = send_read(&state, Method::HEAD, &crop, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_empty());
|
||||
assert_eq!(headers[header::CONTENT_LENGTH], cropped.len().to_string());
|
||||
}
|
||||
|
||||
/// Go takes the transform extension from the URL first — including a fid
|
||||
/// suffix like /vid,fid.png — before the resolved filename's.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -736,6 +736,20 @@ impl Store {
|
||||
vol.read_needle(n)
|
||||
}
|
||||
|
||||
/// Read a needle from a volume using the given read option.
|
||||
///
|
||||
/// On return, `read_option.is_meta_only` tells the caller whether the
|
||||
/// payload was skipped.
|
||||
pub fn read_volume_needle_with_option(
|
||||
&self,
|
||||
vid: VolumeId,
|
||||
n: &mut Needle,
|
||||
read_option: &mut ReadOption,
|
||||
) -> Result<i32, VolumeError> {
|
||||
let (_, vol) = self.find_volume(vid).ok_or(VolumeError::NotFound)?;
|
||||
vol.read_needle_with_option(n, read_option)
|
||||
}
|
||||
|
||||
/// Read a needle from a volume, optionally reading deleted needles.
|
||||
pub fn read_volume_needle_opt(
|
||||
&self,
|
||||
|
||||
@@ -2009,12 +2009,29 @@ impl Volume {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
match self.read_needle_data_at_unlocked(
|
||||
n,
|
||||
nv.offset.to_actual_offset(),
|
||||
read_size,
|
||||
read_option,
|
||||
) {
|
||||
// With `must_meta_only`, read the header and trailer first. If the
|
||||
// needle is compressed or is a chunk manifest, the caller needs its
|
||||
// data, so read the payload as well.
|
||||
//
|
||||
// A version 1 needle has no data size field and no metadata after its
|
||||
// data, so the metadata reader would treat the whole payload as
|
||||
// metadata. It is always read in full.
|
||||
let offset = nv.offset.to_actual_offset();
|
||||
// `is_meta_only` is a result: clear it in case the option is reused.
|
||||
read_option.is_meta_only = false;
|
||||
let meta_first = read_option.must_meta_only && self.version() != VERSION_1;
|
||||
let mut read = if meta_first {
|
||||
self.read_needle_meta_at_unlocked(n, offset, read_size)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if read.is_ok() && meta_first && !n.is_compressed() && !n.is_chunk_manifest() {
|
||||
read_option.is_meta_only = true;
|
||||
}
|
||||
if read.is_ok() && !read_option.is_meta_only {
|
||||
read = self.read_needle_data_at_unlocked(n, offset, read_size, read_option);
|
||||
}
|
||||
match read {
|
||||
Ok(()) => self.check_read_write_error(None),
|
||||
Err(VolumeError::Io(ref e)) => {
|
||||
self.check_read_write_error(Some(e));
|
||||
@@ -6080,6 +6097,120 @@ mod tests {
|
||||
assert_eq!(read_n.cookie, Cookie(0x12345678));
|
||||
}
|
||||
|
||||
/// With `must_meta_only` set, the payload is skipped whatever the needle's
|
||||
/// size. This is the option a delete uses. A chunk manifest is the
|
||||
/// exception: its data is still read, because a delete needs it to find
|
||||
/// the chunks.
|
||||
#[test]
|
||||
fn test_read_needle_must_meta_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut v = make_test_volume(tmp.path().to_str().unwrap());
|
||||
let data = vec![7u8; 8192];
|
||||
let mut plain = Needle {
|
||||
id: NeedleId(1),
|
||||
cookie: Cookie(0x1234),
|
||||
data: data.clone(),
|
||||
data_size: data.len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
v.write_needle(&mut plain, true, false).unwrap();
|
||||
let mut manifest = Needle {
|
||||
id: NeedleId(2),
|
||||
data: b"{}".to_vec(),
|
||||
data_size: 2,
|
||||
..Needle::default()
|
||||
};
|
||||
manifest.set_is_chunk_manifest();
|
||||
v.write_needle(&mut manifest, true, false).unwrap();
|
||||
|
||||
let read = |id: u64, read_option: &mut ReadOption| {
|
||||
let mut n = Needle {
|
||||
id: NeedleId(id),
|
||||
..Needle::default()
|
||||
};
|
||||
v.read_needle_with_option(&mut n, read_option).unwrap();
|
||||
n
|
||||
};
|
||||
let meta_only = || ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
};
|
||||
|
||||
let mut read_option = meta_only();
|
||||
let n = read(1, &mut read_option);
|
||||
assert!(read_option.is_meta_only);
|
||||
assert!(n.data.is_empty(), "{} bytes of data read", n.data.len());
|
||||
assert_eq!(n.cookie, Cookie(0x1234));
|
||||
assert_eq!(n.data_size as usize, data.len());
|
||||
|
||||
// Without `must_meta_only`, the needle is read in full.
|
||||
let mut read_option = ReadOption::default();
|
||||
let n = read(1, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, data);
|
||||
|
||||
let mut read_option = meta_only();
|
||||
let n = read(2, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, b"{}");
|
||||
|
||||
// The option is reused: a metadata-only result from the first read
|
||||
// must not carry over and skip the chunk manifest's data.
|
||||
let mut read_option = meta_only();
|
||||
read(1, &mut read_option);
|
||||
assert!(read_option.is_meta_only);
|
||||
let n = read(2, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, b"{}");
|
||||
}
|
||||
|
||||
/// A version 1 needle is a header, the data and a checksum: it has no data
|
||||
/// size field and no metadata after the data, so the metadata reader would
|
||||
/// take the whole payload for metadata and reject anything over 128 KiB.
|
||||
/// A read with `must_meta_only` must fall back to a full read.
|
||||
#[test]
|
||||
fn test_read_needle_must_meta_only_version_1() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let spec = VolumeSpec {
|
||||
version: VERSION_1,
|
||||
..VolumeSpec::default()
|
||||
};
|
||||
let mut v = Volume::new(dir, dir, VolumeId(1), NeedleMapKind::InMemory, &spec).unwrap();
|
||||
assert_eq!(v.version(), VERSION_1);
|
||||
|
||||
for (id, len) in [(1u64, 11usize), (2, 256 * 1024)] {
|
||||
let data: Vec<u8> = b"hello world".iter().copied().cycle().take(len).collect();
|
||||
// The server only writes version 2 and 3 records, so build the
|
||||
// version 1 record by hand.
|
||||
let size = Size(len as i32);
|
||||
let mut blob = vec![0u8; NEEDLE_HEADER_SIZE];
|
||||
Cookie(0x1234).to_bytes(&mut blob[0..COOKIE_SIZE]);
|
||||
NeedleId(id).to_bytes(&mut blob[COOKIE_SIZE..COOKIE_SIZE + NEEDLE_ID_SIZE]);
|
||||
size.to_bytes(&mut blob[COOKIE_SIZE + NEEDLE_ID_SIZE..NEEDLE_HEADER_SIZE]);
|
||||
blob.extend_from_slice(&data);
|
||||
blob.extend_from_slice(&CRC::new(&data).0.to_be_bytes());
|
||||
blob.resize(get_actual_size(size, VERSION_1) as usize, 0);
|
||||
v.write_needle_blob_and_index(NeedleId(id), &blob, size)
|
||||
.unwrap();
|
||||
|
||||
let mut n = Needle {
|
||||
id: NeedleId(id),
|
||||
..Needle::default()
|
||||
};
|
||||
let mut read_option = ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
};
|
||||
v.read_needle_with_option(&mut n, &mut read_option).unwrap();
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.cookie, Cookie(0x1234));
|
||||
assert_eq!(n.data, data, "{len} byte needle");
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable write goes down the same path and lands the same data; the
|
||||
/// flush is extra work, not different work.
|
||||
#[test]
|
||||
|
||||
Generated
+11
-2
@@ -485,6 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5124,7 +5125,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -5260,7 +5261,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5342,7 +5343,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"prometheus",
|
||||
"prost",
|
||||
"prost-types",
|
||||
@@ -6213,6 +6216,12 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -12,6 +12,10 @@ name = "seaweed_worker_core"
|
||||
# Helpers the Rust volume server needs as well. A path dependency because
|
||||
# the two trees are separate cargo workspaces with no common root manifest.
|
||||
seaweed-common = { path = "../../../seaweed-common" }
|
||||
# Already in the graph through tonic's `tls-aws-lc` feature; named here so the
|
||||
# admin token can sign with the same HMAC the Go worker uses.
|
||||
aws-lc-rs = "1"
|
||||
base64 = "0.22"
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
prost.workspace = true
|
||||
|
||||
@@ -17,6 +17,15 @@ pub struct WorkerOptions {
|
||||
/// section of security.toml. None means plaintext, which is the default the
|
||||
/// Go worker also takes when no certificates are configured.
|
||||
pub tls: Option<TlsOptions>,
|
||||
/// The shared secret the admin stream requires when the cluster configures
|
||||
/// `jwt.filer_signing.key`; mirrored from that config value. Empty means no
|
||||
/// token is attached, which only works against admins that either bound
|
||||
/// their worker listener to loopback or authenticate with mTLS instead.
|
||||
pub signing_key: Option<String>,
|
||||
/// Seconds the minted admin token lives, mirroring
|
||||
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token without an
|
||||
/// expiry, matching the Go default.
|
||||
pub signing_key_expires_after_seconds: u64,
|
||||
}
|
||||
|
||||
/// Certificates for the control stream. All three are required together: the
|
||||
@@ -44,6 +53,8 @@ impl Default for WorkerOptions {
|
||||
max_detection_concurrency: 1,
|
||||
max_execution_concurrency: 1,
|
||||
tls: None,
|
||||
signing_key: None,
|
||||
signing_key_expires_after_seconds: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod metrics;
|
||||
pub mod registry;
|
||||
pub mod senders;
|
||||
pub mod stream;
|
||||
pub mod token;
|
||||
|
||||
/// Generated plugin.proto types.
|
||||
pub mod pb {
|
||||
|
||||
@@ -176,10 +176,19 @@ async fn serve_once(
|
||||
metadata: Default::default(),
|
||||
}))?;
|
||||
|
||||
let mut inbound = client
|
||||
.worker_stream(UnboundedReceiverStream::new(rx))
|
||||
.await?
|
||||
.into_inner();
|
||||
let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx));
|
||||
if let Some(token) = crate::token::admin_token(
|
||||
options.signing_key.as_deref().unwrap_or("").as_bytes(),
|
||||
options.signing_key_expires_after_seconds,
|
||||
) {
|
||||
request.metadata_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {token}")
|
||||
.parse()
|
||||
.context("minted admin token is not valid metadata")?,
|
||||
);
|
||||
}
|
||||
let mut inbound = client.worker_stream(request).await?.into_inner();
|
||||
|
||||
let heartbeat = spawn_heartbeat(
|
||||
sender.clone(),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aws_lc_rs::hmac;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
/// Mints the HS256 Bearer token the admin control stream requires when
|
||||
/// jwt.filer_signing.key is configured, matching the SeaweedFilerAdminClaims
|
||||
/// the Go worker sends: registered claims only, no session id.
|
||||
pub fn admin_token(signing_key: &[u8], expires_after_secs: u64) -> Option<String> {
|
||||
if signing_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let claims = if expires_after_secs > 0 {
|
||||
let exp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
+ expires_after_secs;
|
||||
format!("{{\"exp\":{exp}}}")
|
||||
} else {
|
||||
"{}".to_string()
|
||||
};
|
||||
let signing_input = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"),
|
||||
URL_SAFE_NO_PAD.encode(claims.as_bytes())
|
||||
);
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
|
||||
let signature = hmac::sign(&key, signing_input.as_bytes());
|
||||
Some(format!(
|
||||
"{signing_input}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::admin_token;
|
||||
|
||||
#[test]
|
||||
fn no_key_no_token() {
|
||||
assert!(admin_token(b"", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_token_verifies_shape() {
|
||||
let token = admin_token(b"secret", 60).unwrap();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert!(!parts.iter().any(|p| p.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_expiry_has_empty_claims() {
|
||||
let token = admin_token(b"secret", 0).unwrap();
|
||||
let payload = token.split('.').nth(1).unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let claims = URL_SAFE_NO_PAD.decode(payload).unwrap();
|
||||
assert_eq!(claims, b"{}");
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,18 @@ struct Args {
|
||||
#[arg(long)]
|
||||
tls_server_name: Option<String>,
|
||||
|
||||
/// Shared secret for the admin control stream, the `jwt.filer_signing.key`
|
||||
/// value the Go worker reads from its config file. Required by admins that
|
||||
/// authenticate the worker stream with a signing key.
|
||||
#[arg(long, env = "WEED_FILER_SIGNING_KEY")]
|
||||
filer_signing_key: Option<String>,
|
||||
|
||||
/// Lifetime of the token minted from the signing key, mirroring
|
||||
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token with no
|
||||
/// expiry.
|
||||
#[arg(long, default_value = "0")]
|
||||
filer_signing_key_expires_after_seconds: u64,
|
||||
|
||||
/// Serve /health, /ready and /metrics on this port, the way
|
||||
/// `weed worker -metricsPort` does. Zero, the default, serves nothing.
|
||||
#[arg(long, default_value = "0", env = "WEED_METRICS_PORT")]
|
||||
@@ -131,6 +143,8 @@ async fn main() -> Result<()> {
|
||||
max_detection_concurrency: args.max_concurrency,
|
||||
max_execution_concurrency: args.max_concurrency,
|
||||
tls,
|
||||
signing_key: args.filer_signing_key,
|
||||
signing_key_expires_after_seconds: args.filer_signing_key_expires_after_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package sftp
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
@@ -602,6 +603,32 @@ func TestFileContent(t *testing.T) {
|
||||
sftpClient.Remove("/binary.bin")
|
||||
})
|
||||
|
||||
t.Run("WriteAtOffsetKeepsExistingBytes", func(t *testing.T) {
|
||||
file, err := sftpClient.Create("/resume.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte("hello world"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
// Reopen without O_TRUNC and write past the start, as a resumed upload does
|
||||
file, err = sftpClient.OpenFile("/resume.txt", os.O_WRONLY)
|
||||
require.NoError(t, err)
|
||||
_, err = file.WriteAt([]byte("WORLD!"), 6)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
readFile, err := sftpClient.Open("/resume.txt")
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(readFile)
|
||||
require.NoError(t, err)
|
||||
readFile.Close()
|
||||
|
||||
require.Equal(t, "hello WORLD!", string(content))
|
||||
|
||||
// Clean up
|
||||
sftpClient.Remove("/resume.txt")
|
||||
})
|
||||
|
||||
t.Run("EmptyFile", func(t *testing.T) {
|
||||
file, err := sftpClient.Create("/empty.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -188,6 +188,10 @@ func (s *WorkerGrpcServer) Stop() error {
|
||||
func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
|
||||
ctx := stream.Context()
|
||||
|
||||
if err := security.CheckGrpcAdminPeer(ctx, security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get client address
|
||||
address := findClientAddress(ctx)
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// JobReplicaHosts are the volume-replica hosts a job will read from.
|
||||
// Used so executor pick prefers a worker on one of those machines.
|
||||
// Sources with ShardIds are EC-cleanup rows, not encode replicas.
|
||||
func JobReplicaHosts(job *plugin_pb.JobSpec) []string {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
var addrs []string
|
||||
addrs = append(addrs, pluginworker.ReadStringListConfig(job.Parameters, "replica_servers")...)
|
||||
if src := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", "")); src != "" {
|
||||
addrs = append(addrs, src)
|
||||
}
|
||||
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err == nil {
|
||||
for _, s := range params.GetSources() {
|
||||
if s == nil || s.Node == "" || len(s.ShardIds) > 0 {
|
||||
continue
|
||||
}
|
||||
addrs = append(addrs, s.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return util.HostKeysFromAddresses(addrs)
|
||||
}
|
||||
|
||||
// partitionByPreferredHost splits candidates into (host matches, others).
|
||||
// No preferred hosts, or no matches, returns (nil, all) so callers keep
|
||||
// the existing slots+round-robin path.
|
||||
func partitionByPreferredHost(candidates []*WorkerSession, preferredHosts []string) (pref, rest []*WorkerSession) {
|
||||
want := util.HostKeysFromAddresses(preferredHosts)
|
||||
if len(want) == 0 || len(candidates) == 0 {
|
||||
return nil, candidates
|
||||
}
|
||||
set := make(map[string]struct{}, len(want))
|
||||
for _, h := range want {
|
||||
set[h] = struct{}{}
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[util.HostKey(c.Address)]; ok {
|
||||
pref = append(pref, c)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, c)
|
||||
}
|
||||
if len(pref) == 0 {
|
||||
return nil, candidates
|
||||
}
|
||||
return pref, rest
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -267,6 +269,10 @@ func (r *Plugin) Shutdown() {
|
||||
}
|
||||
|
||||
func (r *Plugin) WorkerStream(stream plugin_pb.PluginControlService_WorkerStreamServer) error {
|
||||
if err := security.CheckGrpcAdminPeer(stream.Context(), security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
@@ -609,7 +615,7 @@ func (r *Plugin) ExecuteJob(
|
||||
releaseGate := r.acquireDetectionExecutionGate(jobType, true)
|
||||
defer releaseGate()
|
||||
|
||||
executor, err := r.registry.PickExecutor(jobType)
|
||||
executor, err := r.registry.PickExecutor(jobType, JobReplicaHosts(job)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -887,6 +887,37 @@ func (r *Plugin) trackExecutionQueued(job *plugin_pb.JobSpec) {
|
||||
})
|
||||
}
|
||||
|
||||
// bindQueuedJobWorker records the home worker while the job stays pending/queued.
|
||||
func (r *Plugin) bindQueuedJobWorker(job *plugin_pb.JobSpec, workerID string) {
|
||||
if job == nil || strings.TrimSpace(job.JobId) == "" || strings.TrimSpace(workerID) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
r.jobsMu.Lock()
|
||||
tracked := r.jobs[job.JobId]
|
||||
if tracked == nil {
|
||||
r.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
if isTerminalTrackedJobState(tracked.State) {
|
||||
r.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
tracked.WorkerID = workerID
|
||||
tracked.Message = "queued on worker " + workerID
|
||||
tracked.UpdatedAt = timeToPtr(now)
|
||||
snap := cloneTrackedJob(*tracked)
|
||||
r.dirtyJobs = true
|
||||
r.jobsMu.Unlock()
|
||||
|
||||
r.persistJobDetailSnapshot(job.JobId, func(detail *TrackedJob) {
|
||||
detail.WorkerID = snap.WorkerID
|
||||
detail.Message = snap.Message
|
||||
detail.UpdatedAt = snap.UpdatedAt
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Plugin) cancelQueuedJob(job *plugin_pb.JobSpec, cause error) {
|
||||
reason := "job canceled"
|
||||
if cause != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -28,12 +29,18 @@ const (
|
||||
defaultScheduledMaxResults int32 = 1000
|
||||
defaultScheduledExecutionConcurrency = 1
|
||||
defaultScheduledPerWorkerConcurrency = 1
|
||||
maxScheduledExecutionConcurrency = 128
|
||||
defaultScheduledRetryBackoff = 5 * time.Second
|
||||
defaultClusterContextTimeout = 10 * time.Second
|
||||
defaultWaitingBacklogFloor = 8
|
||||
defaultWaitingBacklogMultiplier = 4
|
||||
maxEstimatedRuntimeCap = 8 * time.Hour
|
||||
// Hold a job on replica-host workers until their reservation load
|
||||
// reaches this; 0 disables the pin and spills immediately.
|
||||
defaultLocalQueuePin = 5
|
||||
// Wait this long after a home worker disappears before stealing
|
||||
// its queue; 0 steals immediately. Covers a reconnect blink.
|
||||
defaultOrphanRehomeGrace = 30 * time.Second
|
||||
maxScheduledExecutionConcurrency = 128
|
||||
defaultScheduledRetryBackoff = 5 * time.Second
|
||||
defaultClusterContextTimeout = 10 * time.Second
|
||||
defaultWaitingBacklogFloor = 8
|
||||
defaultWaitingBacklogMultiplier = 4
|
||||
maxEstimatedRuntimeCap = 8 * time.Hour
|
||||
// How long started jobs may drain past the run window close.
|
||||
scheduledExecutionDrainGrace = 30 * time.Minute
|
||||
)
|
||||
@@ -49,6 +56,12 @@ type schedulerPolicy struct {
|
||||
PerWorkerConcurrency int
|
||||
RetryLimit int
|
||||
ExecutorReserveBackoff time.Duration
|
||||
// LocalQueuePin is K: do not spill off replica-host workers while
|
||||
// min(load(pref)) < K. Zero is the old immediate-spill path.
|
||||
LocalQueuePin int
|
||||
// OrphanRehomeGrace is how long a missing home's queue is held
|
||||
// before steal-without-K. Zero steals as soon as the worker is gone.
|
||||
OrphanRehomeGrace time.Duration
|
||||
}
|
||||
|
||||
// laneSchedulerLoop is the main scheduling goroutine for a single lane.
|
||||
@@ -466,6 +479,8 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
|
||||
PerWorkerConcurrency: int(adminRuntime.PerWorkerExecutionConcurrency),
|
||||
RetryLimit: int(adminRuntime.RetryLimit),
|
||||
ExecutorReserveBackoff: 200 * time.Millisecond,
|
||||
LocalQueuePin: defaultLocalQueuePin,
|
||||
OrphanRehomeGrace: defaultOrphanRehomeGrace,
|
||||
}
|
||||
|
||||
if policy.DetectionInterval < r.schedulerTick {
|
||||
@@ -496,6 +511,9 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
|
||||
if policy.ExecutionTimeout < defaultScheduledExecutionTimeout {
|
||||
policy.ExecutionTimeout = defaultScheduledExecutionTimeout
|
||||
}
|
||||
if policy.LocalQueuePin < 0 {
|
||||
policy.LocalQueuePin = 0
|
||||
}
|
||||
|
||||
return policy, true, nil
|
||||
}
|
||||
@@ -813,19 +831,22 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
// whole dispatch lets manual shell operations interleave between jobs.
|
||||
jobsRequireLock := LaneRequiresLock(JobTypeLane(jobType))
|
||||
|
||||
jobQueue := make(chan *plugin_pb.JobSpec, len(proposals))
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, len(proposals))
|
||||
for index, proposal := range proposals {
|
||||
job := buildScheduledJobSpec(jobType, proposal, index)
|
||||
r.trackExecutionQueued(job)
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
close(jobQueue)
|
||||
return 0, 0, 0
|
||||
for _, job := range jobs {
|
||||
r.cancelQueuedJob(job, errSchedulerShutdown)
|
||||
}
|
||||
return 0, 0, len(jobs)
|
||||
default:
|
||||
jobQueue <- job
|
||||
}
|
||||
job := buildScheduledJobSpec(jobType, proposal, index)
|
||||
r.trackExecutionQueued(job)
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
close(jobQueue)
|
||||
|
||||
queues := r.assignJobsToWorkerQueues(jobType, jobs, policy)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var statsMu sync.Mutex
|
||||
@@ -842,130 +863,110 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
jobLoop:
|
||||
for job := range jobQueue {
|
||||
for {
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
return
|
||||
}
|
||||
if queues.isEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
item, targetID := r.takeQueuedJob(queues, jobType, policy)
|
||||
if item == nil {
|
||||
if queues.isEmpty() {
|
||||
return
|
||||
}
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
queues.pushFront(item)
|
||||
return
|
||||
}
|
||||
|
||||
executor, release, reserveErr := r.reserveExecutorByID(jobType, policy, targetID)
|
||||
if reserveErr != nil {
|
||||
queues.pushFront(item)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
return
|
||||
default:
|
||||
var releaseJobLock func()
|
||||
if jobsRequireLock {
|
||||
var lockErr error
|
||||
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, item.job.JobId))
|
||||
if lockErr != nil {
|
||||
release()
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: item.job.JobId,
|
||||
JobType: item.job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
releaseJobLock()
|
||||
release()
|
||||
queues.pushFront(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := r.executeScheduledJobWithExecutor(ctx, executor, item.job, clusterContext, policy)
|
||||
if releaseJobLock != nil {
|
||||
releaseJobLock()
|
||||
}
|
||||
release()
|
||||
if errors.Is(err, errExecutorAtCapacity) {
|
||||
queues.pushFront(item)
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
r.cancelQueuedJob(item.job, err)
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
|
||||
executor, release, reserveErr := r.reserveScheduledExecutor(ctx, jobType, policy)
|
||||
if reserveErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobType: jobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution reservation failed: %v", reserveErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
var releaseJobLock func()
|
||||
if jobsRequireLock {
|
||||
var lockErr error
|
||||
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, job.JobId))
|
||||
if lockErr != nil {
|
||||
release()
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: job.JobId,
|
||||
JobType: job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
// The lock may have been held by an operator for a
|
||||
// while; re-check the execution window.
|
||||
if ctx.Err() != nil {
|
||||
releaseJobLock()
|
||||
release()
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
}
|
||||
|
||||
err := r.executeScheduledJobWithExecutor(ctx, executor, job, clusterContext, policy)
|
||||
if releaseJobLock != nil {
|
||||
releaseJobLock()
|
||||
}
|
||||
release()
|
||||
if errors.Is(err, errExecutorAtCapacity) {
|
||||
r.trackExecutionQueued(job)
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
r.cancelQueuedJob(job, err)
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: job.JobId,
|
||||
JobType: job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution failed: %v", err),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
successCount++
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
break
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: item.job.JobId,
|
||||
JobType: item.job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution failed: %v", err),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
successCount++
|
||||
statsMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -976,8 +977,8 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
if drainErr == nil {
|
||||
drainErr = errSchedulerShutdown
|
||||
}
|
||||
for job := range jobQueue {
|
||||
r.cancelQueuedJob(job, drainErr)
|
||||
for _, item := range queues.drain() {
|
||||
r.cancelQueuedJob(item.job, drainErr)
|
||||
canceledCount++
|
||||
}
|
||||
|
||||
@@ -988,6 +989,7 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
ctx context.Context,
|
||||
jobType string,
|
||||
policy schedulerPolicy,
|
||||
preferredHosts []string,
|
||||
) (*WorkerSession, func(), error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -1015,7 +1017,7 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
return nil, nil, fmt.Errorf("timed out waiting for executor capacity for %s", jobType)
|
||||
}
|
||||
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
executors, err := r.registry.ListExecutors(jobType, preferredHosts...)
|
||||
if err != nil {
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
if ctx.Err() != nil {
|
||||
@@ -1026,11 +1028,19 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
continue
|
||||
}
|
||||
|
||||
for _, executor := range executors {
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if !ok {
|
||||
continue
|
||||
pref, rest := partitionByPreferredHost(executors, preferredHosts)
|
||||
lane := JobTypeLane(jobType)
|
||||
loadOf := func(e *WorkerSession) int { return r.executorReservationLoad(e, lane) }
|
||||
|
||||
if len(pref) > 0 {
|
||||
sortExecutorsByLoad(pref, loadOf)
|
||||
if executor, release, ok := r.tryReserveFirst(pref, jobType, policy); ok {
|
||||
return executor, release, nil
|
||||
}
|
||||
}
|
||||
|
||||
sortExecutorsByLoad(rest, loadOf)
|
||||
if executor, release, ok := r.tryReserveFirst(rest, jobType, policy); ok {
|
||||
return executor, release, nil
|
||||
}
|
||||
|
||||
@@ -1051,6 +1061,75 @@ func (r *Plugin) tryReserveExecutorCapacity(
|
||||
return r.tryReserveExecutorCapacityForLane(executor, jobType, policy, JobTypeLane(jobType))
|
||||
}
|
||||
|
||||
func (r *Plugin) tryReserveFirst(executors []*WorkerSession, jobType string, policy schedulerPolicy) (*WorkerSession, func(), bool) {
|
||||
for _, executor := range executors {
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if ok {
|
||||
return executor, release, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (r *Plugin) reserveExecutorByID(jobType string, policy schedulerPolicy, workerID string) (*WorkerSession, func(), error) {
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return nil, nil, fmt.Errorf("executor id is empty")
|
||||
}
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, executor := range executors {
|
||||
if executor == nil || executor.WorkerID != workerID {
|
||||
continue
|
||||
}
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if !ok {
|
||||
return nil, nil, errExecutorAtCapacity
|
||||
}
|
||||
return executor, release, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("executor %s is not available for %s", workerID, jobType)
|
||||
}
|
||||
|
||||
func (r *Plugin) executorReservationLoad(executor *WorkerSession, lane SchedulerLane) int {
|
||||
if executor == nil {
|
||||
return 0
|
||||
}
|
||||
load := 0
|
||||
if executor.Heartbeat != nil && executor.Heartbeat.ExecutionSlotsUsed > 0 {
|
||||
load = int(executor.Heartbeat.ExecutionSlotsUsed)
|
||||
}
|
||||
workerID := strings.TrimSpace(executor.WorkerID)
|
||||
if ls := r.lanes[lane]; ls != nil {
|
||||
ls.execMu.Lock()
|
||||
load += ls.execRes[workerID]
|
||||
ls.execMu.Unlock()
|
||||
return load
|
||||
}
|
||||
r.schedulerExecMu.Lock()
|
||||
load += r.schedulerExecReservations[workerID]
|
||||
r.schedulerExecMu.Unlock()
|
||||
return load
|
||||
}
|
||||
|
||||
func sortExecutorsByLoad(executors []*WorkerSession, load func(*WorkerSession) int) {
|
||||
sort.SliceStable(executors, func(i, j int) bool {
|
||||
if executors[i] == nil {
|
||||
return false
|
||||
}
|
||||
if executors[j] == nil {
|
||||
return true
|
||||
}
|
||||
li, lj := load(executors[i]), load(executors[j])
|
||||
if li != lj {
|
||||
return li < lj
|
||||
}
|
||||
return executors[i].WorkerID < executors[j].WorkerID
|
||||
})
|
||||
}
|
||||
|
||||
// tryReserveExecutorCapacityForLane reserves an execution slot on the
|
||||
// per-lane reservation pool so that lanes cannot starve each other.
|
||||
func (r *Plugin) tryReserveExecutorCapacityForLane(
|
||||
@@ -1577,6 +1656,8 @@ func (r *Plugin) DispatchProposals(
|
||||
ExecutionTimeout: defaultScheduledExecutionTimeout,
|
||||
RetryBackoff: defaultScheduledRetryBackoff,
|
||||
ExecutorReserveBackoff: 200 * time.Millisecond,
|
||||
LocalQueuePin: defaultLocalQueuePin,
|
||||
OrphanRehomeGrace: defaultOrphanRehomeGrace,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ func TestLoadSchedulerPolicyUsesAdminConfig(t *testing.T) {
|
||||
if policy.JobTypeMaxRuntime != 30*time.Minute {
|
||||
t.Fatalf("unexpected max runtime: got=%v", policy.JobTypeMaxRuntime)
|
||||
}
|
||||
if policy.LocalQueuePin != defaultLocalQueuePin {
|
||||
t.Fatalf("unexpected local queue pin: got=%d", policy.LocalQueuePin)
|
||||
}
|
||||
if policy.OrphanRehomeGrace != defaultOrphanRehomeGrace {
|
||||
t.Fatalf("unexpected orphan rehome grace: got=%v", policy.OrphanRehomeGrace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSchedulerPolicyUsesDescriptorDefaultsWhenConfigMissing(t *testing.T) {
|
||||
@@ -138,13 +144,13 @@ func TestReserveScheduledExecutorRespectsPerWorkerLimit(t *testing.T) {
|
||||
ExecutorReserveBackoff: time.Millisecond,
|
||||
}
|
||||
|
||||
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 2: %v", err)
|
||||
}
|
||||
@@ -266,7 +272,7 @@ func TestReserveScheduledExecutorTimesOutWhenNoExecutor(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
pluginSvc.Shutdown()
|
||||
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy)
|
||||
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected reservation shutdown error")
|
||||
}
|
||||
@@ -297,7 +303,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
|
||||
ExecutorReserveBackoff: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 1: %v", err)
|
||||
}
|
||||
@@ -308,7 +314,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
|
||||
}
|
||||
secondReserveCh := make(chan reserveResult, 1)
|
||||
go func() {
|
||||
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if release2 != nil {
|
||||
release2()
|
||||
}
|
||||
@@ -992,3 +998,50 @@ func TestLaneStatus_LifecycleNextDetectionSurvivesDefaultLanePrune(t *testing.T)
|
||||
status.NextDetectionAt, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveWaterfillsPreferredHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
ids := []string{"worker-a", "worker-b", "worker-c", "worker-d"}
|
||||
for _, id := range ids {
|
||||
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: id,
|
||||
Address: "10.0.0.1:9334",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 8,
|
||||
ExecutorReserveBackoff: time.Millisecond,
|
||||
LocalQueuePin: 5,
|
||||
}
|
||||
preferred := []string{"10.0.0.1:8080"}
|
||||
|
||||
seen := map[string]int{}
|
||||
var releases []func()
|
||||
defer func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 4; i++ {
|
||||
exec, release, err := pluginSvc.reserveScheduledExecutor(context.Background(), "erasure_coding", policy, preferred)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve %d: %v", i, err)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
seen[exec.WorkerID]++
|
||||
}
|
||||
if len(seen) != 4 {
|
||||
t.Fatalf("want one job per worker, got %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,14 +277,24 @@ func (r *Registry) PickDetector(jobType string) (*WorkerSession, error) {
|
||||
}
|
||||
|
||||
// PickExecutor picks one executor worker for a job type.
|
||||
func (r *Registry) PickExecutor(jobType string) (*WorkerSession, error) {
|
||||
return r.pickByKind(jobType, false)
|
||||
// preferredHosts, when set, ranks workers whose Hello Address host matches a
|
||||
// replica/source host ahead of others (same-machine EC pull). Empty means the
|
||||
// previous slots+round-robin order.
|
||||
func (r *Registry) PickExecutor(jobType string, preferredHosts ...string) (*WorkerSession, error) {
|
||||
list, err := r.ListExecutors(jobType, preferredHosts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
// ListExecutors returns sorted executor candidates for one job type.
|
||||
// Ordering is by most available execution slots, then lexical worker ID.
|
||||
// The top tie group is rotated round-robin to prevent sticky assignment.
|
||||
func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
|
||||
// preferredHosts, when any worker matches, are listed first (then the rest);
|
||||
// rotation applies only inside the preferred group so a local worker is not
|
||||
// rotated away in favor of a remote one.
|
||||
func (r *Registry) ListExecutors(jobType string, preferredHosts ...string) ([]*WorkerSession, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -293,8 +303,16 @@ func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
|
||||
return nil, fmt.Errorf("no executor worker available for job_type=%s", jobType)
|
||||
}
|
||||
|
||||
sortByKind(candidates, jobType, false)
|
||||
r.rotateTopCandidatesLocked(candidates, jobType, false)
|
||||
pref, rest := partitionByPreferredHost(candidates, preferredHosts)
|
||||
if len(pref) > 0 {
|
||||
sortByKind(pref, jobType, false)
|
||||
r.rotateTopCandidatesLocked(pref, jobType, false)
|
||||
sortByKind(rest, jobType, false)
|
||||
candidates = append(pref, rest...)
|
||||
} else {
|
||||
sortByKind(candidates, jobType, false)
|
||||
r.rotateTopCandidatesLocked(candidates, jobType, false)
|
||||
}
|
||||
|
||||
out := make([]*WorkerSession, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -401,3 +401,70 @@ func TestRegistryHasCapableWorkerIgnoresNilCapability(t *testing.T) {
|
||||
t.Fatalf("expected HasCapableWorker to return false when capability has no detect or execute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExecutorsPrefersMatchingReplicaHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-remote",
|
||||
Address: "10.87.0.4:33646",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-local",
|
||||
Address: "10.87.0.3:33646",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 1},
|
||||
},
|
||||
})
|
||||
r.UpdateHeartbeat("worker-remote", &plugin_pb.WorkerHeartbeat{
|
||||
WorkerId: "worker-remote", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 8,
|
||||
})
|
||||
r.UpdateHeartbeat("worker-local", &plugin_pb.WorkerHeartbeat{
|
||||
WorkerId: "worker-local", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 1,
|
||||
})
|
||||
|
||||
// Without preference the emptier remote worker wins on slots.
|
||||
plain, err := r.ListExecutors("erasure_coding")
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutors: %v", err)
|
||||
}
|
||||
if plain[0].WorkerID != "worker-remote" {
|
||||
t.Fatalf("without preference want remote first, got %s", plain[0].WorkerID)
|
||||
}
|
||||
|
||||
// Replica on :8080 of the same host as worker-local.
|
||||
pref, err := r.ListExecutors("erasure_coding", "10.87.0.3:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutors preferred: %v", err)
|
||||
}
|
||||
if pref[0].WorkerID != "worker-local" {
|
||||
t.Fatalf("preferred host want worker-local first, got %s", pref[0].WorkerID)
|
||||
}
|
||||
if pref[1].WorkerID != "worker-remote" {
|
||||
t.Fatalf("remote should still be fallback, got %s", pref[1].WorkerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobReplicaHostsFromSourcesAndReplicaServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
job := &plugin_pb.JobSpec{
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "10.87.0.1:8080"},
|
||||
},
|
||||
"replica_servers": {
|
||||
Kind: &plugin_pb.ConfigValue_StringList{
|
||||
StringList: &plugin_pb.StringList{Values: []string{"10.87.0.1:8080", "10.87.0.2:8081"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := JobReplicaHosts(job)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
// queuedJob is one scheduled spec parked on a home worker's admin-side queue.
|
||||
type queuedJob struct {
|
||||
job *plugin_pb.JobSpec
|
||||
home string
|
||||
}
|
||||
|
||||
// workerJobQueues holds per-worker FIFOs for one dispatch batch.
|
||||
// Home assignment is done once up front; run goroutines only pop/steal.
|
||||
// leftover is the homeless FIFO: replica hostnames with no currently
|
||||
// registered worker. Orphan grace does not apply here.
|
||||
type workerJobQueues struct {
|
||||
mu sync.Mutex
|
||||
q map[string][]*queuedJob
|
||||
leftover []*queuedJob
|
||||
remaining int
|
||||
missingSince map[string]time.Time
|
||||
homes map[string]struct{}
|
||||
}
|
||||
|
||||
func newWorkerJobQueues() *workerJobQueues {
|
||||
return &workerJobQueues{
|
||||
q: make(map[string][]*queuedJob),
|
||||
missingSince: make(map[string]time.Time),
|
||||
homes: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) hasHome(workerID string) bool {
|
||||
if qs == nil || workerID == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := qs.homes[workerID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) push(home string, job *plugin_pb.JobSpec) {
|
||||
if qs == nil || job == nil {
|
||||
return
|
||||
}
|
||||
item := &queuedJob{job: job, home: home}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
qs.pushLocked(item, false)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) pushFront(item *queuedJob) {
|
||||
if qs == nil || item == nil {
|
||||
return
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
qs.pushLocked(item, true)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) pushLocked(item *queuedJob, front bool) {
|
||||
qs.remaining++
|
||||
if item.home == "" {
|
||||
if front {
|
||||
qs.leftover = append([]*queuedJob{item}, qs.leftover...)
|
||||
return
|
||||
}
|
||||
qs.leftover = append(qs.leftover, item)
|
||||
return
|
||||
}
|
||||
if front {
|
||||
qs.q[item.home] = append([]*queuedJob{item}, qs.q[item.home]...)
|
||||
return
|
||||
}
|
||||
qs.q[item.home] = append(qs.q[item.home], item)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) isEmpty() bool {
|
||||
if qs == nil {
|
||||
return true
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return qs.remaining == 0
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) queueLen(workerID string) int {
|
||||
if qs == nil {
|
||||
return 0
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return len(qs.q[workerID])
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) leftoverLen() int {
|
||||
if qs == nil {
|
||||
return 0
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return len(qs.leftover)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) drain() []*queuedJob {
|
||||
if qs == nil {
|
||||
return nil
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
out := make([]*queuedJob, 0, qs.remaining)
|
||||
for id, qq := range qs.q {
|
||||
out = append(out, qq...)
|
||||
qs.q[id] = nil
|
||||
}
|
||||
out = append(out, qs.leftover...)
|
||||
qs.leftover = nil
|
||||
qs.remaining = 0
|
||||
return out
|
||||
}
|
||||
|
||||
func popFront(qq []*queuedJob) (*queuedJob, []*queuedJob) {
|
||||
item := qq[0]
|
||||
if len(qq) == 1 {
|
||||
return item, nil
|
||||
}
|
||||
return item, qq[1:]
|
||||
}
|
||||
|
||||
// assignJobsToWorkerQueues parks each job on the least-loaded replica-host
|
||||
// worker. A home exists only if a currently registered worker's -address
|
||||
// hostname matches a replica host. Otherwise the job is homeless: global
|
||||
// leftover FIFO, no fake home, no K, no orphan grace. Single-threaded.
|
||||
func (r *Plugin) assignJobsToWorkerQueues(
|
||||
jobType string,
|
||||
jobs []*plugin_pb.JobSpec,
|
||||
_ schedulerPolicy,
|
||||
) *workerJobQueues {
|
||||
qs := newWorkerJobQueues()
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
lane := JobTypeLane(jobType)
|
||||
queued := make(map[string]int, len(executors))
|
||||
|
||||
for _, job := range jobs {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
if err != nil || len(executors) == 0 {
|
||||
qs.push("", job)
|
||||
continue
|
||||
}
|
||||
pref, _ := partitionByPreferredHost(executors, JobReplicaHosts(job))
|
||||
if len(pref) == 0 {
|
||||
// Never registered for that hostname: homeless, not an orphan.
|
||||
qs.push("", job)
|
||||
continue
|
||||
}
|
||||
for _, e := range pref {
|
||||
if e != nil && e.WorkerID != "" {
|
||||
qs.homes[e.WorkerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
sortExecutorsByLoad(pref, func(e *WorkerSession) int {
|
||||
return r.executorReservationLoad(e, lane) + queued[e.WorkerID]
|
||||
})
|
||||
home := pref[0].WorkerID
|
||||
qs.push(home, job)
|
||||
queued[home]++
|
||||
r.bindQueuedJobWorker(job, home)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
// takeQueuedJob pops a job for a free worker.
|
||||
// Home worker (hostname matched a replica / has a home-queue identity):
|
||||
// 1. own home queue
|
||||
// 2. steal from another home if len>=K, or orphan past OrphanRehomeGrace
|
||||
// 3. homeless leftover FIFO
|
||||
// No-home worker (no matching replica, no home-queue identity):
|
||||
// 1. homeless leftover FIFO
|
||||
// 2. only if leftover empty: steal (K / orphan), same rules
|
||||
func (r *Plugin) takeQueuedJob(
|
||||
qs *workerJobQueues,
|
||||
jobType string,
|
||||
policy schedulerPolicy,
|
||||
) (item *queuedJob, targetID string) {
|
||||
if qs == nil {
|
||||
return nil, ""
|
||||
}
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
if err != nil || len(executors) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
lane := JobTypeLane(jobType)
|
||||
pin := policy.LocalQueuePin
|
||||
defer func() {
|
||||
if item != nil && targetID != "" {
|
||||
r.bindQueuedJobWorker(item.job, targetID)
|
||||
}
|
||||
}()
|
||||
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
if qs.remaining == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
var bestHome *WorkerSession
|
||||
bestLoad := 0
|
||||
for _, exec := range executors {
|
||||
if len(qs.q[exec.WorkerID]) == 0 {
|
||||
continue
|
||||
}
|
||||
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
|
||||
if limit <= 0 {
|
||||
continue
|
||||
}
|
||||
load := r.executorReservationLoad(exec, lane)
|
||||
if load >= limit {
|
||||
continue
|
||||
}
|
||||
if bestHome == nil || load < bestLoad || (load == bestLoad && exec.WorkerID < bestHome.WorkerID) {
|
||||
bestHome = exec
|
||||
bestLoad = load
|
||||
}
|
||||
}
|
||||
if bestHome != nil {
|
||||
id := bestHome.WorkerID
|
||||
item, qs.q[id] = popFront(qs.q[id])
|
||||
qs.remaining--
|
||||
return item, id
|
||||
}
|
||||
|
||||
var thief *WorkerSession
|
||||
for _, exec := range executors {
|
||||
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
|
||||
if limit <= 0 {
|
||||
continue
|
||||
}
|
||||
if r.executorReservationLoad(exec, lane) >= limit {
|
||||
continue
|
||||
}
|
||||
if thief == nil || exec.WorkerID < thief.WorkerID {
|
||||
thief = exec
|
||||
}
|
||||
}
|
||||
if thief == nil {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
takeLeftover := func() (*queuedJob, string) {
|
||||
if len(qs.leftover) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
item, qs.leftover = popFront(qs.leftover)
|
||||
qs.remaining--
|
||||
return item, thief.WorkerID
|
||||
}
|
||||
if !qs.hasHome(thief.WorkerID) {
|
||||
if item, id := takeLeftover(); item != nil {
|
||||
return item, id
|
||||
}
|
||||
}
|
||||
|
||||
live := make(map[string]struct{}, len(executors))
|
||||
for _, exec := range executors {
|
||||
live[exec.WorkerID] = struct{}{}
|
||||
delete(qs.missingSince, exec.WorkerID)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
grace := policy.OrphanRehomeGrace
|
||||
victimID := ""
|
||||
victimLen := 0
|
||||
victimOrphan := false
|
||||
for id, qq := range qs.q {
|
||||
if len(qq) == 0 {
|
||||
delete(qs.missingSince, id)
|
||||
continue
|
||||
}
|
||||
_, isLive := live[id]
|
||||
orphan := !isLive
|
||||
if isLive {
|
||||
if pin > 0 && len(qq) < pin {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if _, ok := qs.missingSince[id]; !ok {
|
||||
qs.missingSince[id] = now
|
||||
}
|
||||
if grace > 0 && now.Sub(qs.missingSince[id]) < grace {
|
||||
continue
|
||||
}
|
||||
}
|
||||
better := victimID == ""
|
||||
if !better && orphan != victimOrphan {
|
||||
better = orphan
|
||||
}
|
||||
if !better && orphan == victimOrphan {
|
||||
better = len(qq) > victimLen || (len(qq) == victimLen && id < victimID)
|
||||
}
|
||||
if better {
|
||||
victimID = id
|
||||
victimLen = len(qq)
|
||||
victimOrphan = orphan
|
||||
}
|
||||
}
|
||||
if victimID != "" {
|
||||
item, qs.q[victimID] = popFront(qs.q[victimID])
|
||||
qs.remaining--
|
||||
return item, thief.WorkerID
|
||||
}
|
||||
return takeLeftover()
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func replicaJob(id, source string) *plugin_pb.JobSpec {
|
||||
return &plugin_pb.JobSpec{
|
||||
JobId: id,
|
||||
JobType: "erasure_coding",
|
||||
Summary: id,
|
||||
DedupeKey: id,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func registerECWorkers(t *testing.T, pluginSvc *Plugin, workers map[string]string) {
|
||||
t.Helper()
|
||||
for id, addr := range workers {
|
||||
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: id,
|
||||
Address: addr,
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignJobsWaterfillsPreferredWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-a": "10.0.0.1:9334",
|
||||
"worker-b": "10.0.0.2:9334",
|
||||
"worker-c": "10.0.0.3:9334",
|
||||
"worker-d": "10.0.0.4:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("j1", "10.0.0.1:8080"),
|
||||
replicaJob("j2", "10.0.0.1:8080"),
|
||||
replicaJob("j3", "10.0.0.1:8080"),
|
||||
replicaJob("j4", "10.0.0.1:8080"),
|
||||
}
|
||||
// All four replica IPs listed so every worker is preferred.
|
||||
for _, job := range jobs {
|
||||
job.Parameters["replica_servers"] = &plugin_pb.ConfigValue{
|
||||
Kind: &plugin_pb.ConfigValue_StringList{StringList: &plugin_pb.StringList{
|
||||
Values: []string{"10.0.0.1:8080", "10.0.0.2:8080", "10.0.0.3:8080", "10.0.0.4:8080"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, schedulerPolicy{LocalQueuePin: 5})
|
||||
if qs.queueLen("worker-a")+qs.queueLen("worker-b")+qs.queueLen("worker-c")+qs.queueLen("worker-d") != 4 {
|
||||
t.Fatalf("expected 4 assigned jobs")
|
||||
}
|
||||
for _, id := range []string{"worker-a", "worker-b", "worker-c", "worker-d"} {
|
||||
if qs.queueLen(id) != 1 {
|
||||
t.Fatalf("want 1 job on %s, got %d", id, qs.queueLen(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignAndTakeDoesNotStarveOtherHomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("local-1", "10.0.0.1:8080"),
|
||||
replicaJob("local-2", "10.0.0.1:8080"),
|
||||
replicaJob("remote-1", "10.0.0.2:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 1 {
|
||||
t.Fatalf("homes local=%d remote=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"))
|
||||
}
|
||||
|
||||
_, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || target2 != "worker-remote" {
|
||||
t.Fatalf("second take should start remote home, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOnlyWhenHomeQueueReachesK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 {
|
||||
t.Fatalf("all jobs should home on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// 5 still queued on local (>=K) and local has no slot → steal to remote.
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("expected steal to remote, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 4 {
|
||||
t.Fatalf("after steal want 4 left on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
// 4 < K: do not steal again.
|
||||
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatalf("pin should hold remaining jobs, got target=%s", tgt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsImmediatelyWhenPinDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("a", "10.0.0.1:8080"),
|
||||
replicaJob("b", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 0}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("K=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOrphanedHomeWithoutK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("o1", "10.0.0.1:8080"),
|
||||
replicaJob("o2", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 {
|
||||
t.Fatalf("want 2 on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
|
||||
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatalf("within grace should hold orphan queue, got target=%s", tgt)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 2 {
|
||||
t.Fatalf("held queue changed: %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
qs.mu.Lock()
|
||||
qs.missingSince["worker-local"] = time.Now().Add(-time.Minute)
|
||||
qs.mu.Unlock()
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("after grace should steal without K, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 1 {
|
||||
t.Fatalf("want 1 orphan left, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
stolen2, stealTarget2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen2 == nil || stealTarget2 != "worker-remote" {
|
||||
t.Fatalf("second orphan should also move, got job=%v target=%s", stolen2, stealTarget2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeKeepsOrphanWhenHomeReconnectsDuringGrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{replicaJob("blink", "10.0.0.1:8080")}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
if item, _ := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatal("expected hold during grace")
|
||||
}
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{"worker-local": "10.0.0.1:9334"})
|
||||
item, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item == nil || target != "worker-local" {
|
||||
t.Fatalf("reconnected home should run its queue, got job=%v target=%s", item, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOrphanImmediatelyWhenGraceDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{replicaJob("o1", "10.0.0.1:8080")}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5, OrphanRehomeGrace: 0}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("grace=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindQueuedJobWorkerKeepsPendingState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
job := replicaJob("bind-1", "10.0.0.1:8080")
|
||||
pluginSvc.trackExecutionQueued(job)
|
||||
pluginSvc.bindQueuedJobWorker(job, "worker-local")
|
||||
|
||||
pluginSvc.jobsMu.Lock()
|
||||
tracked := pluginSvc.jobs[job.JobId]
|
||||
pluginSvc.jobsMu.Unlock()
|
||||
if tracked == nil {
|
||||
t.Fatal("missing tracked job")
|
||||
}
|
||||
if tracked.State != "job_state_pending" {
|
||||
t.Fatalf("state=%s", tracked.State)
|
||||
}
|
||||
if tracked.Stage != "queued" {
|
||||
t.Fatalf("stage=%s", tracked.Stage)
|
||||
}
|
||||
if tracked.WorkerID != "worker-local" {
|
||||
t.Fatalf("worker=%s", tracked.WorkerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomelessAllRemoteWorkersUseFIFOWithoutPinOrGrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
// Workers never registered on the replica hostname — homeless, not orphan.
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-a": "10.0.0.5:9334",
|
||||
"worker-b": "10.0.0.6:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("h1", "10.0.0.1:8080"),
|
||||
replicaJob("h2", "10.0.0.1:8080"),
|
||||
replicaJob("h3", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-a") != 0 || qs.queueLen("worker-b") != 0 {
|
||||
t.Fatalf("homeless jobs must not get a fake home, a=%d b=%d", qs.queueLen("worker-a"), qs.queueLen("worker-b"))
|
||||
}
|
||||
if qs.leftoverLen() != 3 {
|
||||
t.Fatalf("want 3 homeless FIFO, got %d", qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "h1" {
|
||||
t.Fatalf("FIFO first want h1, got %v", item1)
|
||||
}
|
||||
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "h2" {
|
||||
t.Fatalf("FIFO second want h2 immediately, got %v", item2)
|
||||
}
|
||||
if target2 == "" || target2 == target1 {
|
||||
t.Fatalf("second take should hit the other idle worker, t1=%s t2=%s", target1, target2)
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("want 1 homeless left, got %d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedReplicaLocalJobsStayHomeBeforeHomeless(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("local-1", "10.0.0.1:8080"),
|
||||
replicaJob("local-2", "10.0.0.1:8080"),
|
||||
replicaJob("homeless-1", "10.0.0.9:8080"),
|
||||
replicaJob("homeless-2", "10.0.0.9:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 0 || qs.leftoverLen() != 2 {
|
||||
t.Fatalf("homes local=%d remote=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"), qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "local-1" || target1 != "worker-local" {
|
||||
t.Fatalf("first take want local-1 on home, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-remote" {
|
||||
t.Fatalf("remote should take homeless, not steal pinned home, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 1 {
|
||||
t.Fatalf("remaining replica-local job must stay home, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("one homeless should remain, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleHomeWorkerTakesHomelessAfterEmptyHomeQueue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("home-1", "10.0.0.1:8080"),
|
||||
replicaJob("homeless-1", "10.0.0.9:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 1 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "home-1" || target1 != "worker-local" {
|
||||
t.Fatalf("first take want home-1, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-local" {
|
||||
t.Fatalf("idle home with empty queue may take homeless, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
if qs.leftoverLen() != 0 {
|
||||
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomelessWorkerTakesFIFOBeforeStealableHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 7)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
|
||||
}
|
||||
if qs.hasHome("worker-remote") {
|
||||
t.Fatal("remote must have no home identity")
|
||||
}
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item, takeTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item == nil || takeTarget != "worker-remote" || item.job.JobId != "homeless-1" {
|
||||
t.Fatalf("remote must take homeless FIFO before K-steal, got job=%v target=%s", item, takeTarget)
|
||||
}
|
||||
if qs.leftoverLen() != 0 {
|
||||
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
if qs.queueLen("worker-local") != 5 {
|
||||
t.Fatalf("stealable home must stay until FIFO empty, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" || stolen.job.JobId == "homeless-1" {
|
||||
t.Fatalf("after FIFO empty remote may steal >=K, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeWorkerStealsBeforeHomelessAfterOwnQueueEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-other": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 8)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
jobs = append(jobs, replicaJob("other-1", "10.0.0.2:8080"))
|
||||
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 || qs.queueLen("worker-other") != 1 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("local=%d other=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-other"), qs.leftoverLen())
|
||||
}
|
||||
if !qs.hasHome("worker-other") {
|
||||
t.Fatal("other must keep home identity")
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || target1 != "worker-local" {
|
||||
t.Fatalf("first take want local home, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "other-1" || target2 != "worker-other" {
|
||||
t.Fatalf("other should drain own home first, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-other" || stolen.job.JobId == "homeless-1" {
|
||||
t.Fatalf("home worker with empty own queue still steals before homeless, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("homeless should still wait, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
|
||||
return updateLocalEntry(option, message.NewParentPath, message.NewEntry, remoteEntry)
|
||||
}
|
||||
if filer_pb.IsDelete(resp) {
|
||||
return processDeleteEvent(client, mountedDir, remoteStorageMountLocation, resp)
|
||||
return processDeleteEvent(filerSource, client, mountedDir, remoteStorageMountLocation, resp)
|
||||
}
|
||||
if message.OldEntry != nil && message.NewEntry != nil {
|
||||
return processUpdateEvent(option, filerSource, *option.storageClass, client, mountedDir, remoteStorageMountLocation, resp)
|
||||
@@ -212,8 +212,11 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
|
||||
// event the inline delete already handled), and GCS reports that case as
|
||||
// ErrRemoteObjectNotFound where S3 and Azure answer an idempotent success.
|
||||
// Returning the error would pin the sync offset on an event that has nothing
|
||||
// left to do.
|
||||
// left to do. A file whose path a remote-only entry has taken since keeps its
|
||||
// object: a rename copied that entry's content to this key, and it exists
|
||||
// nowhere else.
|
||||
func processDeleteEvent(
|
||||
filerSource filer_pb.FilerClient,
|
||||
client remote_storage.RemoteStorageClient,
|
||||
mountedDir string,
|
||||
remoteStorageMountLocation *remote_pb.RemoteStorageLocation,
|
||||
@@ -232,6 +235,36 @@ func processDeleteEvent(
|
||||
}
|
||||
glog.V(2).Infof("delete: %+v", resp)
|
||||
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
|
||||
if !message.OldEntry.IsDirectory {
|
||||
// a deleted entry whose rename copy was still pending read its
|
||||
// content from the source its marker names, which nothing else
|
||||
// releases
|
||||
if err := releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
// The object at the key may have been replaced since the delete: a
|
||||
// rename copies another entry's content onto it, a rewrite uploads
|
||||
// over it, and a copy can land before its entry is visible. Delete
|
||||
// only an object the event's stamp still describes.
|
||||
if live, statErr := client.StatFile(dest); statErr == nil {
|
||||
if oldRemote := message.OldEntry.GetRemoteEntry(); oldRemote == nil ||
|
||||
!describes(oldRemote, live) ||
|
||||
oldRemote.RemoteETag != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: the object changed since the delete", remote_storage.FormatLocation(dest))
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(statErr, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return statErr
|
||||
}
|
||||
current, err := currentEntry(filerSource, resp.Directory, message.OldEntry.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
glog.V(0).Infof("keep %s: a remote-only entry has taken its path since", remote_storage.FormatLocation(dest))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if message.OldEntry.IsDirectory {
|
||||
glog.V(0).Infof("rmdir %s", remote_storage.FormatLocation(dest))
|
||||
return client.RemoveDirectory(dest)
|
||||
@@ -240,6 +273,41 @@ func processDeleteEvent(
|
||||
return deleteRemoteFile(client, dest)
|
||||
}
|
||||
|
||||
// releasePendingSource deletes the source a pending-rename marker on entry
|
||||
// names, once nothing needs it. The filer never deletes a marked source: a
|
||||
// concurrent rename can copy onto that key before its entry is visible. So
|
||||
// the object goes only while it is still the one entry's stamp describes, and
|
||||
// no remote-only entry holds its path.
|
||||
func releasePendingSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) error {
|
||||
src := util.FullPath(entry.GetExtended()[filer.ExtRemoteRenameSourceKey])
|
||||
if src == "" || !strings.HasPrefix(string(src), strings.TrimSuffix(mountedDir, "/")+"/") {
|
||||
return nil
|
||||
}
|
||||
srcDest := toRemoteStorageLocation(util.FullPath(mountedDir), src, remoteStorageMountLocation)
|
||||
srcDir, srcName := src.DirAndName()
|
||||
current, err := currentEntry(filerSource, srcDir, srcName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
glog.V(0).Infof("keep %s: a remote-only entry holds the pending rename's source", remote_storage.FormatLocation(srcDest))
|
||||
return nil
|
||||
}
|
||||
live, err := client.StatFile(srcDest)
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stamp := entry.GetRemoteEntry(); !describes(stamp, live) || stamp.RemoteETag != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: the pending rename's source changed since", remote_storage.FormatLocation(srcDest))
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("delete %s, the source of a pending rename", remote_storage.FormatLocation(srcDest))
|
||||
return deleteRemoteFile(client, srcDest)
|
||||
}
|
||||
|
||||
// deleteRemoteFile deletes the object and treats an already-absent object as
|
||||
// deleted.
|
||||
func deleteRemoteFile(client remote_storage.RemoteStorageClient, dest *remote_pb.RemoteStorageLocation) error {
|
||||
@@ -268,6 +336,17 @@ func processUpdateEvent(
|
||||
}
|
||||
oldDest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
|
||||
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(message.NewParentPath, message.NewEntry.Name), remoteStorageMountLocation)
|
||||
eventSource := renameSource{path: util.NewFullPath(resp.Directory, message.OldEntry.Name), loc: oldDest, stamp: message.OldEntry.GetRemoteEntry()}
|
||||
// sourceOf is where a remote-only entry's content is: the source its
|
||||
// pending-rename marker names, which may be an earlier rename's old key,
|
||||
// or else the event's old key
|
||||
sourceOf := func(entry *filer_pb.Entry, fallback renameSource) renameSource {
|
||||
if src := string(entry.Extended[filer.ExtRemoteRenameSourceKey]); strings.HasPrefix(src, strings.TrimSuffix(mountedDir, "/")+"/") {
|
||||
// a pending entry carries the stamp of the source it reads
|
||||
return renameSource{path: util.FullPath(src), loc: toRemoteStorageLocation(util.FullPath(mountedDir), util.FullPath(src), remoteStorageMountLocation), stamp: entry.GetRemoteEntry()}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
if proto.Equal(oldDest, dest) && !shouldSendToRemote(message.NewEntry) {
|
||||
glog.V(2).Infof("skipping updating: %+v", resp)
|
||||
return nil
|
||||
@@ -307,7 +386,23 @@ func processUpdateEvent(
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
return completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, oldDest, dest, storageClass)
|
||||
source := sourceOf(current, eventSource)
|
||||
if err := completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, source, dest, storageClass); err != nil {
|
||||
return err
|
||||
}
|
||||
// a pending entry the filer copied on this rename leaves the
|
||||
// source its marker named
|
||||
if pending := sourceOf(message.OldEntry, renameSource{}); pending.loc != nil && !proto.Equal(pending.loc, source.loc) {
|
||||
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if current == nil && len(message.NewEntry.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
// the rename's copy was pending and the entry has moved on: a
|
||||
// later rename carries the source in its own marker and finishes
|
||||
// or releases it, so this one leaves it
|
||||
glog.V(0).Infof("keep %s: the pending rename moved on", remote_storage.FormatLocation(oldDest))
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("delete %s", remote_storage.FormatLocation(oldDest))
|
||||
if err := client.DeleteFile(oldDest); err != nil {
|
||||
@@ -323,14 +418,43 @@ func processUpdateEvent(
|
||||
if errors.Is(writeErr, errSuperseded) {
|
||||
glog.Errorf("skipping %s: %v", remote_storage.FormatLocation(dest), writeErr)
|
||||
if !proto.Equal(oldDest, dest) {
|
||||
return uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, oldDest, dest, storageClass)
|
||||
if err := uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, eventSource, dest, storageClass, sourceOf); err != nil {
|
||||
return err
|
||||
}
|
||||
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry)
|
||||
if err := updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
if !proto.Equal(oldDest, dest) {
|
||||
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseRenamedSource releases a pending source the renamed entry's old
|
||||
// version read, once the destination holds the content on the remote under
|
||||
// its own key: only after the upload and its stamp, which clears the marker,
|
||||
// and only if the entry does not read through a marker any more. Released
|
||||
// earlier, an upload that fails followed by remote.uncache would leave the
|
||||
// entry reading a deleted source.
|
||||
func releaseRenamedSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, message *filer_pb.EventNotification) error {
|
||||
if len(message.OldEntry.GetExtended()[filer.ExtRemoteRenameSourceKey]) == 0 {
|
||||
return nil
|
||||
}
|
||||
current, err := currentEntry(filerSource, message.NewParentPath, message.NewEntry.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
return nil
|
||||
}
|
||||
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
|
||||
}
|
||||
|
||||
// currentEntry returns what the filer holds at dir/name now, nil when the
|
||||
@@ -359,11 +483,24 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
|
||||
// copied over the destination, the entry is stamped with the copy, and the
|
||||
// old key goes. With neither object present the content is lost: the event
|
||||
// fails and holds the offset for recovery.
|
||||
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, source renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
oldDest := source.loc
|
||||
// the source's recorded stamp; a retry can find the entry already
|
||||
// stamped with the copy, whose ETag a multipart source does not share
|
||||
sourceStamp := source.stamp
|
||||
if sourceStamp == nil {
|
||||
sourceStamp = proto.Clone(current.RemoteEntry).(*filer_pb.RemoteEntry)
|
||||
}
|
||||
if existing, err := client.StatFile(dest); err == nil {
|
||||
if describes(current.RemoteEntry, existing) {
|
||||
glog.V(0).Infof("%s already holds the renamed content", remote_storage.FormatLocation(dest))
|
||||
return deleteRemoteFile(client, oldDest)
|
||||
if len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
// reads still go to the source until the stamp clears the marker
|
||||
if err := updateLocalEntry(filerClient, dir, current, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
|
||||
}
|
||||
glog.V(0).Infof("%s holds an object the entry does not describe (size %d, want %d); replacing it from %s", remote_storage.FormatLocation(dest), existing.RemoteSize, current.RemoteEntry.RemoteSize, remote_storage.FormatLocation(oldDest))
|
||||
} else if !errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
@@ -389,7 +526,42 @@ func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_st
|
||||
if err := updateLocalEntry(filerClient, dir, current, remoteEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteRemoteFile(client, oldDest)
|
||||
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
|
||||
}
|
||||
|
||||
// renameSource is where a renamed remote-only entry's content was read
|
||||
// from: the path, the object it maps to, and the stamp describing it.
|
||||
type renameSource struct {
|
||||
path util.FullPath
|
||||
loc *remote_pb.RemoteStorageLocation
|
||||
stamp *filer_pb.RemoteEntry
|
||||
}
|
||||
|
||||
// deleteRenameSource deletes a rename's old key once the renamed entry no
|
||||
// longer reads it: an entry still carrying the pending-source marker (its
|
||||
// stamp lost a race) keeps the source, and the event is retried. The key is
|
||||
// left alone when the object there is no longer the one stamp describes, as
|
||||
// when another rename copied onto it, visible yet or not.
|
||||
func deleteRenameSource(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, source renameSource, stamp *filer_pb.RemoteEntry) error {
|
||||
current, err := currentEntry(filerClient, dir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
return fmt.Errorf("%s still reads %s", util.NewFullPath(dir, name), remote_storage.FormatLocation(source.loc))
|
||||
}
|
||||
live, err := client.StatFile(source.loc)
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !describes(stamp, live) || stamp.GetRemoteETag() != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: it is no longer the renamed content", remote_storage.FormatLocation(source.loc))
|
||||
return nil
|
||||
}
|
||||
return deleteRemoteFile(client, source.loc)
|
||||
}
|
||||
|
||||
// describes reports whether the object a stat returned is the one the entry's
|
||||
@@ -417,15 +589,15 @@ func openRemoteObject(client remote_storage.RemoteStorageClient, loc *remote_pb.
|
||||
}
|
||||
|
||||
// uploadCurrentEntry uploads what the filer holds at dir/name now, when the
|
||||
// event that superseded this rename will not. A superseded rename has
|
||||
// already deleted the old key; the rewrite behind it in the log uploads the
|
||||
// destination itself whenever shouldSendToRemote sees it as unsynced, so
|
||||
// only a current entry that reads synced is uploaded here and the content
|
||||
// goes up once. A remote-only entry is finished the way
|
||||
// completeRemoteOnlyRename finishes a rename: its stamp can already describe
|
||||
// the destination (a sync plus remote.uncache in the meantime), and only
|
||||
// then is it complete.
|
||||
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
// event that superseded this rename will not. A rename whose snapshot is
|
||||
// superseded has already deleted the old key; the rewrite behind it in the
|
||||
// log uploads the destination itself unless shouldSendToRemote skips it on
|
||||
// the inherited RemoteEntry, whose RemoteMtime can equal the rewrite's mtime
|
||||
// within the same second. Only that case uploads here, so the content goes
|
||||
// up once. A remote-only entry is finished the way completeRemoteOnlyRename
|
||||
// finishes a rename: its stamp can already describe the destination (a sync
|
||||
// plus remote.uncache in the meantime), and only then is it complete.
|
||||
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldSource renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string, sourceOf func(*filer_pb.Entry, renameSource) renameSource) error {
|
||||
current, _, _, err := filer_pb.GetEntry(context.Background(), filerSource, util.NewFullPath(dir, name))
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil
|
||||
@@ -437,7 +609,7 @@ func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.F
|
||||
return nil
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
return completeRemoteOnlyRename(filerClient, client, dir, current, oldDest, dest, storageClass)
|
||||
return completeRemoteOnlyRename(filerClient, client, dir, current, sourceOf(current, oldSource), dest, storageClass)
|
||||
}
|
||||
if shouldSendToRemote(current) {
|
||||
glog.V(0).Infof("leaving %s to the rewrite that superseded the rename", remote_storage.FormatLocation(dest))
|
||||
@@ -634,6 +806,7 @@ func updateLocalEntry(filerClient filer_pb.FilerClient, dir string, entry *filer
|
||||
remoteEntry.LastLocalSyncTsNs = time.Now().UnixNano()
|
||||
expected := proto.Clone(entry).(*filer_pb.Entry)
|
||||
entry.RemoteEntry = remoteEntry
|
||||
delete(entry.Extended, filer.ExtRemoteRenameSourceKey)
|
||||
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -686,12 +687,20 @@ type recordingRemote struct {
|
||||
// chunk ids of each written entry: which version of the file went up
|
||||
writtenChunks [][]string
|
||||
deleteErr error
|
||||
writeErr error
|
||||
// objects present on the remote, by path: StatFile and ReadFile answer
|
||||
// from it, everything else is ErrRemoteObjectNotFound.
|
||||
objects map[string][]byte
|
||||
// statETag is the ETag StatFile reports for a present object, unless
|
||||
// statETags names one for its path.
|
||||
statETag string
|
||||
statETags map[string]string
|
||||
}
|
||||
|
||||
func (r *recordingRemote) WriteFile(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry, reader io.Reader) (*filer_pb.RemoteEntry, error) {
|
||||
if r.writeErr != nil {
|
||||
return nil, r.writeErr
|
||||
}
|
||||
r.writes = append(r.writes, loc)
|
||||
var ids []string
|
||||
for _, c := range entry.GetChunks() {
|
||||
@@ -714,7 +723,11 @@ func (r *recordingRemote) StatFile(loc *remote_pb.RemoteStorageLocation) (*filer
|
||||
if !ok {
|
||||
return nil, remote_storage.ErrRemoteObjectNotFound
|
||||
}
|
||||
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body))}, nil
|
||||
etag := r.statETag
|
||||
if e, ok := r.statETags[loc.Path]; ok {
|
||||
etag = e
|
||||
}
|
||||
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body)), RemoteETag: etag}, nil
|
||||
}
|
||||
|
||||
func (r *recordingRemote) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) ([]byte, error) {
|
||||
@@ -1008,7 +1021,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
|
||||
t.Run("absent object", func(t *testing.T) {
|
||||
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err != nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v, want nil: deleting an absent object is complete", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
||||
@@ -1020,7 +1033,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
marked := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
marked.EventNotification.OldEntry.Extended = map[string][]byte{filer.ExtKeepRemoteObjectKey: []byte("true")}
|
||||
remote := &recordingRemote{}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, marked); err != nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, marked); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
@@ -1028,9 +1041,108 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a remote-only entry that took the path keeps the object", func(t *testing.T) {
|
||||
// deleted, then a remote-only file renamed onto the path: the rename
|
||||
// copied its content to this key before the sync got to the delete
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
|
||||
Name: "state.db.tmp",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 10},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
|
||||
}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a replaced object is kept", func(t *testing.T) {
|
||||
// the delete recorded the old object's etag; a rename copied another
|
||||
// entry's content onto the key since, and it must not go with it
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{Name: "state.db.tmp", Content: []byte("copied")}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a changed object with no entry at the path yet is kept", func(t *testing.T) {
|
||||
// a directory rename copies a child onto the key before its new entry
|
||||
// becomes visible; the copy may be the content's only remaining home
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unchanged object still goes", func(t *testing.T) {
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}, statETag: `"orig"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 {
|
||||
t.Errorf("deletes = %+v, want one", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a copy that landed before its entry is kept", func(t *testing.T) {
|
||||
// the deleted entry was never replicated, so its event records no
|
||||
// stamp to match the fresh rename copy waiting for its filer entry
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unverifiable object is kept", func(t *testing.T) {
|
||||
// the delete recorded an etag the live stat does not report; the
|
||||
// object cannot be confirmed as the one the delete meant
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an entry with local data at the path does not stop the delete", func(t *testing.T) {
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
|
||||
Name: "state.db.tmp",
|
||||
Content: []byte("local"),
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
|
||||
}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 {
|
||||
t.Errorf("deletes = %+v, want one", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other failure still fails", func(t *testing.T) {
|
||||
remote := &recordingRemote{deleteErr: errors.New("AccessDenied: Access Denied")}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err == nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err == nil {
|
||||
t.Fatal("err = nil, want the delete failure")
|
||||
}
|
||||
})
|
||||
@@ -1281,7 +1393,7 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 1024})
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, "")
|
||||
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource)
|
||||
if err == nil || !strings.Contains(err.Error(), "neither") {
|
||||
t.Errorf("err = %v, want the lost-content failure", err)
|
||||
}
|
||||
@@ -1292,9 +1404,9 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
|
||||
t.Run("uncached with the destination already synced: complete without a write", func(t *testing.T) {
|
||||
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/b.txt": []byte("payload")}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload"), "/b/dir/b.txt": []byte("payload")}}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, ""); err != nil {
|
||||
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource); err != nil {
|
||||
t.Fatalf("err = %v, want nil: the destination object matches the entry's stamp", err)
|
||||
}
|
||||
if len(remote.writes) != 0 {
|
||||
@@ -1306,6 +1418,228 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameClearsThePendingSourceFirst(t *testing.T) {
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/src/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/dst/a.jpg"}
|
||||
pending := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "a.jpg",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 4},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 4},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/src/a.jpg")},
|
||||
}
|
||||
}
|
||||
objects := func() map[string][]byte {
|
||||
return map[string][]byte{"/b/src/a.jpg": []byte("data"), "/b/dst/a.jpg": []byte("data")}
|
||||
}
|
||||
|
||||
t.Run("stamped, then the source goes", func(t *testing.T) {
|
||||
// the destination already holds the copy; the entry still reads the
|
||||
// source until its stamp clears the marker
|
||||
current := pending()
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", current, renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if filerClient.updates != 1 {
|
||||
t.Errorf("updates = %d, want the stamp that clears the marker", filerClient.updates)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/src/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the source", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a marker still in place keeps the source", func(t *testing.T) {
|
||||
// the stamp did not land: the filer still has the marker
|
||||
filerClient := &stubFilerClient{entry: pending()}
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", pending(), renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err == nil {
|
||||
t.Fatal("err = nil, want the event retried")
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none while the entry reads the source", remote.deletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eventSource resolves a rename's source to the event's old key.
|
||||
func eventSource(_ *filer_pb.Entry, source renameSource) renameSource {
|
||||
return source
|
||||
}
|
||||
|
||||
func TestRenameOfAPendingEntryFollowsItsSource(t *testing.T) {
|
||||
// A was renamed over B and B over C, both copies failing: C reads A's
|
||||
// object through the marker, and B's key holds the old target's bytes
|
||||
const mountedDir = "/buckets"
|
||||
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
||||
remoteOnly := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}
|
||||
pendingC := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "c.jpg",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
|
||||
RemoteEntry: remoteOnly,
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
|
||||
}
|
||||
}
|
||||
event := func(oldName, newName string) *filer_pb.SubscribeMetadataResponse {
|
||||
newEntry := pendingC()
|
||||
newEntry.Name = newName
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/buckets/b",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: oldName, RemoteEntry: remoteOnly},
|
||||
NewParentPath: "/buckets/b",
|
||||
NewEntry: newEntry,
|
||||
},
|
||||
}
|
||||
}
|
||||
objects := func() map[string][]byte {
|
||||
return map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("old-c")}
|
||||
}
|
||||
|
||||
t.Run("the first rename leaves the source to the entry that moved on", func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: objects()}
|
||||
filerClient := &stubFilerClient{} // nothing at b.jpg any more
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("a.jpg", "b.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 0 || len(remote.writes) != 0 {
|
||||
t.Errorf("deletes = %+v, writes = %+v, want neither: c.jpg still reads a.jpg", remote.deletes, remote.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the second rename copies from the marker's source", func(t *testing.T) {
|
||||
current := pendingC()
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"a"`}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("b.jpg", "c.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.written) != 1 || string(remote.written[0]) != "data-a" {
|
||||
t.Errorf("written = %q, want a.jpg's content", remote.written)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the source once the stamp cleared the marker", remote.deletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPendingRenameSourceIsReleasedOnlyWhileUnchanged(t *testing.T) {
|
||||
const mountedDir = "/buckets"
|
||||
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
||||
// b.jpg's copy failed after it replaced a target: it read a.jpg's object
|
||||
pendingB := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "b.jpg",
|
||||
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
|
||||
}
|
||||
}
|
||||
deleted := func() *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{OldEntry: pendingB()}}
|
||||
}
|
||||
deletedPaths := func(r *recordingRemote) (paths []string) {
|
||||
for _, d := range r.deletes {
|
||||
paths = append(paths, d.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("deleting the pending entry releases its source", func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the source", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a source replaced since is kept", func(t *testing.T) {
|
||||
// another rename copied onto a.jpg before its entry became visible
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d")}, statETag: `"d"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the replaced source kept", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a destination cached since releases the source too", func(t *testing.T) {
|
||||
// c.jpg was cached before the sync reached the rename: it is no
|
||||
// longer remote-only, and a.jpg is still left over
|
||||
cached := &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: cached,
|
||||
}}
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": cached}}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the pending source released", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a cached entry still reading the source keeps it until its upload lands", func(t *testing.T) {
|
||||
// c.jpg was cached through its marker; until the upload is stamped
|
||||
// and the marker cleared, a.jpg is what remote.uncache would fall
|
||||
// back to
|
||||
marked := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")}}
|
||||
}
|
||||
for name, writeErr := range map[string]error{"upload fails": errors.New("upload failed"), "stamp does not land": nil} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b")}, statETag: `"a"`, writeErr: writeErr}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: marked(),
|
||||
}}
|
||||
// the filer keeps the marker: the stub does not store updates
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": marked()}}
|
||||
_ = processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event)
|
||||
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the source kept while the entry reads it", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a later rename the filer copied releases the source", func(t *testing.T) {
|
||||
// b.jpg was renamed to c.jpg and the filer copied a.jpg there at once
|
||||
copied := &filer_pb.Entry{Name: "c.jpg", Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: copied,
|
||||
}}
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": copied}}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the pending source released", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pathFilerClient serves entries by path; a path it lacks is not found.
|
||||
type pathFilerClient struct {
|
||||
stubFilerClient
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func (c *pathFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: c.entries[req.Directory+"/"+req.Name]}, nil
|
||||
}
|
||||
|
||||
func (c *pathFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c)
|
||||
}
|
||||
|
||||
func TestShouldSendToRemote(t *testing.T) {
|
||||
const mtime = 1786096669
|
||||
|
||||
@@ -1383,3 +1717,37 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
|
||||
t.Error("the restamped entry should read synced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
|
||||
// A was renamed to B, then D to A before the sync got to the first
|
||||
// rename: the object at A is now D's, and the only copy of it
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
|
||||
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d"), "/b/b.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
remote.statETags = map[string]string{"/b/a.jpg": `"d"`}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b", current, renameSource{path: "/buckets/b/a.jpg", loc: oldDest}, dest, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want the reused key kept", remote.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameRetryUsesTheSourceStamp(t *testing.T) {
|
||||
// an earlier run copied a multipart source and stamped the entry with
|
||||
// the copy's ETag, then failed to delete the source
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
|
||||
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("data-a")},
|
||||
statETags: map[string]string{"/b/a.jpg": `"a-2"`, "/b/b.jpg": `"copy"`}}
|
||||
source := renameSource{path: "/buckets/b/a.jpg", loc: oldDest, stamp: &filer_pb.RemoteEntry{RemoteETag: `"a-2"`, RemoteSize: 6}}
|
||||
if err := completeRemoteOnlyRename(&stubFilerClient{entry: current}, remote, "/buckets/b", current, source, dest, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the unchanged source deleted on the retry", remote.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func runPluginWorkerWithOptions(options pluginWorkerRunOptions) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir)
|
||||
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir, options.Address)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to build plugin worker handlers: %v", err)
|
||||
return false
|
||||
@@ -141,7 +141,7 @@ func resolvePluginWorkerID(explicitID string, workingDir string) (string, error)
|
||||
// buildPluginWorkerHandlers resolves the comma-separated jobTypes string
|
||||
// (which may contain category names like "all", "default", "heavy" and/or
|
||||
// explicit job type names/aliases) into a deduplicated slice of JobHandlers.
|
||||
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string) ([]pluginworker.JobHandler, error) {
|
||||
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string, workerAddress ...string) ([]pluginworker.JobHandler, error) {
|
||||
jobTypes = strings.TrimSpace(jobTypes)
|
||||
if jobTypes == "" {
|
||||
jobTypes = defaultPluginWorkerJobTypes
|
||||
@@ -152,10 +152,16 @@ func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxE
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := ""
|
||||
if len(workerAddress) > 0 {
|
||||
addr = strings.TrimSpace(workerAddress[0])
|
||||
}
|
||||
|
||||
opts := pluginworker.HandlerBuildOptions{
|
||||
GrpcDialOption: dialOption,
|
||||
MaxExecute: maxExecute,
|
||||
WorkingDir: workingDir,
|
||||
WorkerAddress: addr,
|
||||
}
|
||||
|
||||
handlers := make([]pluginworker.JobHandler, 0, len(factories))
|
||||
|
||||
@@ -458,13 +458,13 @@ func retriedStreamFetchChunkData(ctx context.Context, writer io.Writer, urlStrin
|
||||
localProcessed += writtenCount
|
||||
totalWritten += writtenCount
|
||||
})
|
||||
if !shouldRetry {
|
||||
break
|
||||
}
|
||||
if writeErr != nil {
|
||||
err = writeErr
|
||||
break
|
||||
}
|
||||
if !shouldRetry {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
failed = true
|
||||
glog.V(0).InfofCtx(ctx, "read %s failed, err: %v", urlString, err)
|
||||
|
||||
@@ -787,3 +787,23 @@ func TestFetchWholeChunkUsesProvidedFilerJwt(t *testing.T) {
|
||||
return ""
|
||||
}))
|
||||
}
|
||||
|
||||
// A completed fetch whose destination write failed is a failed copy, not a
|
||||
// short success: the caller must see the write error, or a temp disk filling
|
||||
// mid-copy would still upload the truncated file.
|
||||
func TestRetriedStreamFetchChunkDataReportsWriteError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("0123456789"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
written, err := retriedStreamFetchChunkData(context.Background(), &failOnWrite{}, []string{srv.URL + "/5,abc"}, "", nil, false, true, 0, 0, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Zero(t, written)
|
||||
}
|
||||
|
||||
type failOnWrite struct{}
|
||||
|
||||
func (w *failOnWrite) Write(p []byte) (int, error) {
|
||||
return 0, fmt.Errorf("no space left")
|
||||
}
|
||||
|
||||
@@ -82,6 +82,11 @@ type Filer struct {
|
||||
persistedLogCache *persistedLogCache
|
||||
metaLogInflight metaLogInflight
|
||||
remoteTombstones *remoteDeletionTombstones
|
||||
// EntryLockTable serializes mutations per entry path. The filer server
|
||||
// holds it across local read-modify-write mutations and the meta
|
||||
// aggregator across a replayed peer change's check-and-apply, so the two
|
||||
// cannot interleave on the same path.
|
||||
EntryLockTable *util.LockTable[util.FullPath]
|
||||
// remoteTombstonesDone, when non-nil, is closed once the startup tombstone
|
||||
// rebuild finishes; lazy remote reads wait on it so a pending delete
|
||||
// cannot resurrect in the gap.
|
||||
@@ -110,6 +115,12 @@ type Filer struct {
|
||||
// overwriting the unread ledger with a partial set.
|
||||
deletionLedgerBlocked atomic.Bool
|
||||
deletionLedgerFlush chan struct{}
|
||||
// metaTsPending holds per-path commit records whose KV write failed,
|
||||
// keyed by path; ordering reads consult it until the retry lands them
|
||||
// (see meta_path_ts.go). metaTsRepairing marks paths with a live
|
||||
// repair goroutine so failures neither strand a record nor pile on.
|
||||
metaTsPending sync.Map
|
||||
metaTsRepairing sync.Map
|
||||
// flushCtx is the context metadata-log flushes append under. It stays
|
||||
// live for the process lifetime and is cancelled once during Shutdown,
|
||||
// bounding every retry - in-flight and queued alike - to one deadline.
|
||||
@@ -133,6 +144,7 @@ func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerH
|
||||
DeletionRetryQueue: NewDeletionRetryQueue(),
|
||||
persistedLogCache: newPersistedLogCache(persistedLogCacheMaxBytes),
|
||||
remoteTombstones: newRemoteDeletionTombstones(),
|
||||
EntryLockTable: util.NewLockTable[util.FullPath](),
|
||||
}
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
if f.UniqueFilerId < 0 {
|
||||
@@ -913,6 +925,12 @@ func (f *Filer) Shutdown() {
|
||||
f.LocalMetaLogBuffer.ShutdownLogBuffer()
|
||||
// The final metadata-log flush still needs the store to append its entry.
|
||||
f.LocalMetaLogBuffer.WaitForShutdown()
|
||||
// Stop peer subscriptions and let each persist its last applied offset;
|
||||
// the replay on the next start otherwise resumes from a stale offset and
|
||||
// applies those old events over newer local state.
|
||||
if f.MetaAggregator != nil {
|
||||
f.MetaAggregator.Shutdown()
|
||||
}
|
||||
// Persist the deletion ledger one last time before the store closes, so a
|
||||
// clean shutdown leaves the recovery set exactly consistent with reality.
|
||||
f.snapshotDeletionLedger()
|
||||
|
||||
@@ -225,3 +225,97 @@ func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool,
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteFromRemote deletes the object, or for a directory every object, the
|
||||
// entry maps to on its mounted remote storage.
|
||||
func (f *Filer) DeleteFromRemote(ctx context.Context, entry *Entry) error {
|
||||
_, err := f.maybeDeleteFromRemote(ctx, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtRemoteRenameSourceKey marks a remote-only entry whose rename committed
|
||||
// while its copy to the new key was still pending: it holds the path whose
|
||||
// object remote-only reads must serve until the copy stamps the entry.
|
||||
const ExtRemoteRenameSourceKey = "Seaweed-X-Remote-Rename-Source"
|
||||
|
||||
// RemoteSourcePath returns the path whose remote object holds the content of
|
||||
// the remote-only entry at p: the renamed-from path a pending deferred copy
|
||||
// still reads, or p itself. A marker leading out of p's mount is ignored, so
|
||||
// whoever can set extended attributes cannot point reads at another mount.
|
||||
func (f *Filer) RemoteSourcePath(entry *Entry, p util.FullPath) util.FullPath {
|
||||
src := util.FullPath(entry.Extended[ExtRemoteRenameSourceKey])
|
||||
if src == "" || f.RemoteStorage == nil {
|
||||
return p
|
||||
}
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(p)
|
||||
if remoteLoc == nil {
|
||||
return p
|
||||
}
|
||||
if srcMountDir, _ := f.RemoteStorage.FindMountDirectory(src); srcMountDir != mountDir {
|
||||
return p
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// RemoteReadPath returns the dir and name whose object a remote-only read of
|
||||
// the entry at dir/name serves.
|
||||
func (f *Filer) RemoteReadPath(entry *Entry, dir, name string) (string, string) {
|
||||
return f.RemoteSourcePath(entry, util.NewFullPath(dir, name)).DirAndName()
|
||||
}
|
||||
|
||||
// CopyRemoteOnlyEntry copies the remote object of a renamed entry whose
|
||||
// content exists only on the remote to the key of newPath, and returns the
|
||||
// RemoteEntry of the copy. The rename deletes the old key along with the old
|
||||
// entry, and with no local data nothing would write the new key. It returns
|
||||
// nil when no copy is needed: the entry has local data, which the remote sync
|
||||
// uploads to the new key, it is empty, which the sync writes as an empty
|
||||
// object, or it is not under a remote mount. A rename that
|
||||
// moves such an entry out of its mount, or onto a remote that cannot copy, is
|
||||
// refused, since it would delete the only copy of the content.
|
||||
func (f *Filer) CopyRemoteOnlyEntry(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (*filer_pb.RemoteEntry, error) {
|
||||
src, dst, copier, err := f.RemoteCopyLocations(ctx, entry, oldPath, newPath)
|
||||
if err != nil || copier == nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteEntry, err := copier.CopyFile(src, dst)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy remote %s to %s: %w", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst), err)
|
||||
}
|
||||
glog.V(3).InfofCtx(ctx, "CopyRemoteOnlyEntry: copied %s to %s", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst))
|
||||
return remoteEntry, nil
|
||||
}
|
||||
|
||||
// RemoteCopyLocations resolves the object locations a remote-only rename copy
|
||||
// of entry between oldPath and newPath would use, and the client able to copy
|
||||
// it. A nil copier means the entry needs no copy; an error means it needs one
|
||||
// that cannot be served, which a rename must refuse before committing.
|
||||
func (f *Filer) RemoteCopyLocations(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (src, dst *remote_pb.RemoteStorageLocation, copier remote_storage.RemoteStorageObjectCopier, err error) {
|
||||
if f.RemoteStorage == nil || entry.IsDirectory() || !entry.IsInRemoteOnly() || len(entry.Content) > 0 {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(oldPath)
|
||||
if remoteLoc == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
if newMountDir, _ := f.RemoteStorage.FindMountDirectory(newPath); newMountDir != mountDir {
|
||||
return nil, nil, nil, fmt.Errorf("%s exists only on the remote of mount %s and cannot be renamed out of it; cache it first", oldPath, mountDir)
|
||||
}
|
||||
|
||||
remoteConf, found := f.RemoteStorage.GetRemoteStorageConf(remoteLoc.Name)
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: not found", oldPath)
|
||||
}
|
||||
client, clientErr := f.buildRemoteStorageClient(ctx, remoteConf)
|
||||
if clientErr != nil {
|
||||
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: %w", oldPath, clientErr)
|
||||
}
|
||||
copier, ok := client.(remote_storage.RemoteStorageObjectCopier)
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("%s exists only on the remote and remote storage type %s cannot copy it to the new name; cache it first", oldPath, remoteConf.Type)
|
||||
}
|
||||
|
||||
src = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, f.RemoteSourcePath(entry, oldPath))
|
||||
dst = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, newPath)
|
||||
return src, dst, copier, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type stubFilerStore struct {
|
||||
kv map[string][]byte
|
||||
insertErr error
|
||||
findErr error
|
||||
kvPutErr error
|
||||
kvGetErr error
|
||||
kvDeleteErr error
|
||||
kvGetHook func(key []byte)
|
||||
@@ -49,6 +50,12 @@ func newStubFilerStore() *stubFilerStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) setKvPutErr(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kvPutErr = err
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) Initialize(util.Configuration, string) error { return nil }
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
@@ -60,6 +67,9 @@ func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil
|
||||
func (s *stubFilerStore) KvPut(_ context.Context, key []byte, value []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.kvPutErr != nil {
|
||||
return s.kvPutErr
|
||||
}
|
||||
s.kv[string(key)] = append([]byte(nil), value...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ func (f *Filer) notifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
|
||||
}
|
||||
|
||||
f.logMetaEvent(ctx, event)
|
||||
// Record the commit timestamp so a peer replay ordered by the entry lock
|
||||
// this mutation may still be under sees the local change, including as a
|
||||
// tombstone after the entry itself is gone.
|
||||
f.noteCommittedEvent(event)
|
||||
if sink := metadataEventSinkFromContext(ctx); sink != nil {
|
||||
sink.Record(event)
|
||||
}
|
||||
|
||||
+196
-54
@@ -63,6 +63,11 @@ type MetaAggregator struct {
|
||||
flushAdvanced chan struct{}
|
||||
remotePeerArrived chan struct{}
|
||||
peerWatermarksLock sync.Mutex
|
||||
peerWg sync.WaitGroup
|
||||
// stopped is set under peerChansLock by Shutdown; OnPeerUpdate then
|
||||
// rejects adds so no subscription can outlive the shutdown wait and run
|
||||
// against a store Filer.Shutdown has already closed.
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// MetaAggregator only aggregates data "on the fly". The logs are not re-persisted to disk.
|
||||
@@ -87,6 +92,13 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
|
||||
ma.peerChansLock.Lock()
|
||||
defer ma.peerChansLock.Unlock()
|
||||
|
||||
// After Shutdown the master connection may still deliver an update; a
|
||||
// subscription started now would have no stop signal and could run past
|
||||
// the store close.
|
||||
if ma.stopped {
|
||||
return
|
||||
}
|
||||
|
||||
address := pb.ServerAddress(update.Address)
|
||||
if update.IsAdd {
|
||||
// the peer is already followed, restarting would only lose the events
|
||||
@@ -102,7 +114,11 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
|
||||
// Account for the peer before its stream signals; keep prior values
|
||||
// on reconnect.
|
||||
ma.initPeerWatermark(address)
|
||||
go ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
|
||||
ma.peerWg.Add(1)
|
||||
go func() {
|
||||
defer ma.peerWg.Done()
|
||||
ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
|
||||
}()
|
||||
} else {
|
||||
if prevChan, found := ma.peerChans[address]; found {
|
||||
close(prevChan)
|
||||
@@ -344,6 +360,42 @@ func (ma *MetaAggregator) HasPeer(address pb.ServerAddress) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Shutdown stops every peer subscription and waits for each to return,
|
||||
// letting the deferred offset save persist the last applied position before
|
||||
// the store closes. Without it a graceful restart resumes from the last
|
||||
// periodically-saved offset — up to a minute stale — and replays those
|
||||
// events over newer local state.
|
||||
func (ma *MetaAggregator) Shutdown() {
|
||||
ma.peerChansLock.Lock()
|
||||
ma.stopped = true
|
||||
chans := ma.peerChans
|
||||
ma.peerChans = make(map[pb.ServerAddress]chan struct{})
|
||||
for _, stopChan := range chans {
|
||||
close(stopChan)
|
||||
}
|
||||
ma.peerChansLock.Unlock()
|
||||
|
||||
// Wait without a cap: every blocking point in the subscribe loop is
|
||||
// stop-aware, so a peer still running here is genuinely wedged and
|
||||
// returning anyway would let Filer.Shutdown close the store under it and
|
||||
// skip its deferred offset save.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ma.peerWg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
glog.Warningf("meta aggregator shutdown: peer subscriptions still stopping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddress, peer pb.ServerAddress, startFrom time.Time, stopChan chan struct{}) {
|
||||
lastTsNs := startFrom.UnixNano()
|
||||
for {
|
||||
@@ -368,7 +420,12 @@ func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddres
|
||||
if lastTsNs < nextLastTsNs {
|
||||
lastTsNs = nextLastTsNs
|
||||
}
|
||||
time.Sleep(1733 * time.Millisecond)
|
||||
select {
|
||||
case <-stopChan:
|
||||
glog.V(0).Infof("stop subscribing peer %s meta change", peer)
|
||||
return
|
||||
case <-time.After(1733 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,11 +441,34 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
|
||||
*/
|
||||
|
||||
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse)
|
||||
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse) error
|
||||
lastPersistTime := time.Now()
|
||||
lastTsNs := startFrom
|
||||
// replayFloorTsNs is the earliest event this session gave up replaying.
|
||||
// The persisted offset and the reconnect resume point must stay behind
|
||||
// it so the event is redelivered on the next subscribe instead of being
|
||||
// skipped forever.
|
||||
var replayFloorTsNs int64
|
||||
persistableTsNs := func(tsNs int64) int64 {
|
||||
if replayFloorTsNs > 0 && replayFloorTsNs-1 < tsNs {
|
||||
return replayFloorTsNs - 1
|
||||
}
|
||||
return tsNs
|
||||
}
|
||||
|
||||
peerSignature, err := ma.readFilerStoreSignature(peer)
|
||||
// Everything below derives from ctx, cancelled when the peer is removed
|
||||
// or the aggregator shuts down, so no stage can block past its stop.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-stopChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
peerSignature, err := ma.readFilerStoreSignature(ctx, peer)
|
||||
if err != nil {
|
||||
return lastTsNs, fmt.Errorf("connecting to peer filer %s: %v", peer, err)
|
||||
}
|
||||
@@ -408,7 +488,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
// different concept and can be set to arbitrary values.
|
||||
preTraverseTime := time.Now()
|
||||
glog.V(0).Infof("no previous offset for peer %s, starting full metadata sync", peer)
|
||||
if traverseErr := ma.traversePeerMetadata(f, peer); traverseErr != nil {
|
||||
if traverseErr := ma.traversePeerMetadata(ctx, f, peer); traverseErr != nil {
|
||||
return lastTsNs, fmt.Errorf("initial metadata sync from %s: %v", peer, traverseErr)
|
||||
}
|
||||
lastTsNs = preTraverseTime.Add(-time.Minute).UnixNano()
|
||||
@@ -420,11 +500,12 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
return lastTsNs, fmt.Errorf("read offset for peer %s: %w", peer, err)
|
||||
}
|
||||
defer func(prevTsNs int64) {
|
||||
if lastTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, lastTsNs); err == nil {
|
||||
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
saveTsNs := persistableTsNs(lastTsNs)
|
||||
if saveTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, saveTsNs); err == nil {
|
||||
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
|
||||
} else {
|
||||
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
|
||||
}
|
||||
}
|
||||
}(lastTsNs)
|
||||
@@ -432,11 +513,23 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
glog.V(0).Infof("follow peer: %v, last %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
var counter int64
|
||||
var synced bool
|
||||
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) {
|
||||
replicateMetadataChange(f.Store, peer, event)
|
||||
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) error {
|
||||
if err := replicateMetadataChange(f, peer, event, stopChan); err != nil {
|
||||
if errors.Is(err, errPeerSubscriptionStopped) {
|
||||
return err
|
||||
}
|
||||
// A failed event becomes a barrier the persisted offset and
|
||||
// the reconnect resume point cannot pass, while the stream
|
||||
// keeps flowing: blocking on an event that can never replay
|
||||
// would stall every later event from this peer.
|
||||
if replayFloorTsNs == 0 || event.TsNs < replayFloorTsNs {
|
||||
replayFloorTsNs = event.TsNs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
counter++
|
||||
if lastPersistTime.Add(time.Minute).Before(time.Now()) {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, event.TsNs); err == nil {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, persistableTsNs(event.TsNs)); err == nil {
|
||||
if event.TsNs < time.Now().Add(-2*time.Minute).UnixNano() {
|
||||
glog.V(0).Infof("sync with %s progressed to: %v %0.2f/sec", peer, time.Unix(0, event.TsNs), float64(counter)/60.0)
|
||||
} else if !synced {
|
||||
@@ -449,6 +542,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
glog.V(0).Infof("failed to update offset for %v: %v", peer, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +559,9 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
return err
|
||||
}
|
||||
if maybeReplicateMetadataChange != nil {
|
||||
maybeReplicateMetadataChange(event)
|
||||
if err := maybeReplicateMetadataChange(event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -476,17 +572,8 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
|
||||
glog.V(0).Infof("subscribing remote %s meta change: %v, clientId:%d", peer, time.Unix(0, lastTsNs), ma.filer.UniqueFilerId)
|
||||
err = pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Stop the stream promptly on removal; the blocking Recv below would
|
||||
// otherwise only notice when the stream errors on its own.
|
||||
go func() {
|
||||
select {
|
||||
case <-stopChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
// ctx is cancelled on stopChan by the watch above; the blocking Recv
|
||||
// below stops promptly on removal or shutdown.
|
||||
atomic.AddInt32(&ma.filer.UniqueFilerEpoch, 1)
|
||||
// Construct a log file reader that reads chunks via the peer filer's LookupVolume.
|
||||
lookupFn := LookupFn(filerClient{client})
|
||||
@@ -511,6 +598,14 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
}
|
||||
|
||||
processOne := func(event *filer_pb.SubscribeMetadataResponse) error {
|
||||
// Bail promptly on stop; a cancelled stream may still deliver
|
||||
// queued events while unwinding, and replaying them now only
|
||||
// delays the offset save the shutdown is waiting on.
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
default:
|
||||
}
|
||||
if err := processEventFn(event); err != nil {
|
||||
glog.V(0).Infof("SubscribeLocalMetadata process %v: %v", event, err)
|
||||
return fmt.Errorf("process %v: %w", event, err)
|
||||
@@ -587,19 +682,37 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
}
|
||||
}
|
||||
})
|
||||
return lastTsNs, err
|
||||
return persistableTsNs(lastTsNs), err
|
||||
}
|
||||
|
||||
// replicateMetadataChange retries transient Replay failures with bounded
|
||||
// backoff. A failure that outlives the retry budget is counted and logged,
|
||||
// then skipped: blocking on an event that can never replay would stall every
|
||||
// later event from this peer, which is worse than one entry staying stale.
|
||||
func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse) {
|
||||
err := util.Retry("replicate metadata change from "+string(peer), func() error {
|
||||
return Replay(store, event)
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
// backoff and reports the outcome. errPeerSubscriptionStopped is returned
|
||||
// when the subscription stops mid-retry so the caller aborts and the offset
|
||||
// save keeps the last completed event. A failure that outlives the retry
|
||||
// budget is counted and logged, then returned: the caller pins the peer's
|
||||
// offset at the event so a restart replays it rather than skipping it.
|
||||
func replicateMetadataChange(f *Filer, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse, stopChan <-chan struct{}) error {
|
||||
waitTime := time.Second
|
||||
var err error
|
||||
for waitTime < util.RetryWaitTime {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
default:
|
||||
}
|
||||
if err = f.Replay(event); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !util.IsTransientError(err) {
|
||||
break
|
||||
}
|
||||
glog.V(0).Infof("retry replicate metadata change from %s: %v", peer, err)
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
case <-time.After(waitTime):
|
||||
}
|
||||
waitTime += waitTime / 2
|
||||
}
|
||||
stats.FilerMetaAggregatorReplayFailures.WithLabelValues(string(peer)).Inc()
|
||||
name := event.GetEventNotification().GetNewEntry().GetName()
|
||||
@@ -607,15 +720,20 @@ func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *fil
|
||||
name = event.GetEventNotification().GetOldEntry().GetName()
|
||||
}
|
||||
glog.Errorf("giving up replicating metadata change from %s for %s/%s (ts=%d): %v", peer, event.Directory, name, event.TsNs, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// errPeerSubscriptionStopped ends event processing once the subscription's
|
||||
// stop channel closed.
|
||||
var errPeerSubscriptionStopped = errors.New("peer subscription stopped")
|
||||
|
||||
// traversePeerMetadata does a full BFS traversal of a peer filer's metadata
|
||||
// and inserts all entries into the local store. This is used when a filer
|
||||
// connects to a peer for the first time and needs to bootstrap pre-existing data.
|
||||
func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress) error {
|
||||
// The ctx cancels on the peer's stop channel so a shutdown does not have to
|
||||
// out-wait a long traversal.
|
||||
func (ma *MetaAggregator) traversePeerMetadata(ctx context.Context, f *Filer, peer pb.ServerAddress) error {
|
||||
return pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stream, err := client.TraverseBfsMetadata(ctx, &filer_pb.TraverseBfsMetadataRequest{
|
||||
Directory: "/",
|
||||
ExcludedPrefixes: []string{SystemLogDir},
|
||||
@@ -635,22 +753,17 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
|
||||
if resp.Entry == nil {
|
||||
continue
|
||||
}
|
||||
fullpath := util.Join(resp.Directory, resp.Entry.Name)
|
||||
entry := FromPbEntry(resp.Directory, resp.Entry)
|
||||
if insertErr := f.Store.InsertEntry(context.Background(), entry); insertErr != nil {
|
||||
// Entry may already exist (root dir, or partial previous bootstrap).
|
||||
existing, findErr := f.Store.FindEntry(context.Background(), entry.FullPath)
|
||||
if findErr != nil {
|
||||
return fmt.Errorf("insert entry %s: %w", fullpath, insertErr)
|
||||
}
|
||||
// Only overwrite if the peer's entry is newer.
|
||||
if entry.Attr.Mtime.After(existing.Attr.Mtime) {
|
||||
if updateErr := f.Store.UpdateEntry(context.Background(), entry); updateErr != nil {
|
||||
return fmt.Errorf("update entry %s: %w", fullpath, updateErr)
|
||||
}
|
||||
} else {
|
||||
glog.V(1).Infof("skip older peer entry %s (peer mtime %v <= local mtime %v)", fullpath, entry.Attr.Mtime, existing.Attr.Mtime)
|
||||
}
|
||||
// Order on the sender's per-path commit timestamp, not the
|
||||
// entry's mtime — mtime is client-settable and does not track
|
||||
// when the peer committed the change. Peers that predate the
|
||||
// field send 0 and fall back to mtime.
|
||||
peerTsNs := resp.EntryTsNs
|
||||
if peerTsNs == 0 {
|
||||
peerTsNs = entry.Attr.Mtime.UnixNano()
|
||||
}
|
||||
if applyErr := f.applyTraversedEntry(ctx, entry, peerTsNs); applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
count++
|
||||
if count%10000 == 0 {
|
||||
@@ -662,9 +775,38 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
|
||||
})
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) readFilerStoreSignature(peer pb.ServerAddress) (sig int32, err error) {
|
||||
// applyTraversedEntry merges one bootstrap entry under the path's replay
|
||||
// locks, keeping local state when its commit is newer than the sender's —
|
||||
// most importantly a delete, whose tombstone must not let the bootstrap
|
||||
// resurrect the path. An applied entry records the sender's commit time so a
|
||||
// later peer replay orders against it rather than the entry's mtime.
|
||||
func (f *Filer) applyTraversedEntry(ctx context.Context, entry *Entry, peerTsNs int64) error {
|
||||
unlock := f.lockReplayPaths(entry.FullPath, "")
|
||||
defer unlock()
|
||||
localTsNs, err := f.localCommitTsNs(ctx, entry.FullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check local state for %s: %w", entry.FullPath, err)
|
||||
}
|
||||
if localTsNs >= peerTsNs {
|
||||
return nil
|
||||
}
|
||||
if insertErr := f.Store.InsertEntry(ctx, entry); insertErr != nil {
|
||||
// Entry already exists (root dir, or partial previous bootstrap);
|
||||
// the lock kept the check above current, so the peer commit is newer.
|
||||
if _, findErr := f.Store.FindEntry(ctx, entry.FullPath); findErr != nil {
|
||||
return fmt.Errorf("insert entry %s: %w", entry.FullPath, insertErr)
|
||||
}
|
||||
if updateErr := f.Store.UpdateEntry(ctx, entry); updateErr != nil {
|
||||
return fmt.Errorf("update entry %s: %w", entry.FullPath, updateErr)
|
||||
}
|
||||
}
|
||||
f.notePathChange(entry.FullPath, peerTsNs, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) readFilerStoreSignature(ctx context.Context, peer pb.ServerAddress) (sig int32, err error) {
|
||||
err = pb.WithFilerClient(false, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// newReplayTestFiler builds the minimum Filer Replay needs over a stub store.
|
||||
func newReplayTestFiler(store FilerStore) *Filer {
|
||||
return &Filer{
|
||||
Store: NewFilerStoreWrapper(store),
|
||||
EntryLockTable: util.NewLockTable[util.FullPath](),
|
||||
}
|
||||
}
|
||||
|
||||
// flakyReplayStore fails InsertEntry for its first failUntil calls, then
|
||||
// delegates to the embedded stubFilerStore.
|
||||
type flakyReplayStore struct {
|
||||
@@ -73,8 +81,11 @@ func TestReplicateMetadataChangeRetriesTransientFailure(t *testing.T) {
|
||||
err: errors.New("i/o timeout talking to store"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-transient:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
replicateMetadataChange(store, peer, quotaChangeEvent("my-bucket", wantQuota))
|
||||
if err := replicateMetadataChange(f, peer, quotaChangeEvent("my-bucket", wantQuota), nil); err != nil {
|
||||
t.Fatalf("replicateMetadataChange: %v", err)
|
||||
}
|
||||
|
||||
inserted, err := store.FindEntry(context.Background(), util.NewFullPath("/buckets", "my-bucket"))
|
||||
if err != nil {
|
||||
@@ -97,10 +108,14 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
|
||||
err: errors.New("entry checksum mismatch"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-permanent:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
before := replayFailureCount(t, peer)
|
||||
|
||||
replicateMetadataChange(store, peer, quotaChangeEvent("poison-bucket", 65536<<20))
|
||||
err := replicateMetadataChange(f, peer, quotaChangeEvent("poison-bucket", 65536<<20), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the exhausted replay error so the caller can pin the peer offset at the failed event")
|
||||
}
|
||||
|
||||
if got := store.attemptCount(); got != 1 {
|
||||
t.Fatalf("attempts = %d, want exactly 1: a non-transient error must fail fast, not retry", got)
|
||||
@@ -109,3 +124,587 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
|
||||
t.Fatalf("FilerMetaAggregatorReplayFailures[%s] = %v, want %v", peer, got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func overwriteEvent(dir, name string, tsNs int64, content string) *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: name},
|
||||
NewEntry: &filer_pb.Entry{Name: name, Content: []byte(content)},
|
||||
},
|
||||
TsNs: tsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: name},
|
||||
},
|
||||
TsNs: tsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySkipsEventOlderThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now},
|
||||
Content: []byte("newer local write"),
|
||||
}
|
||||
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "old replayed version")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "newer local write" {
|
||||
t.Fatalf("content = %q, want local write kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAppliesEventNewerThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Minute)},
|
||||
Content: []byte("old local write"),
|
||||
}
|
||||
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.UnixNano(), "newer peer write")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "newer peer write" {
|
||||
t.Fatalf("content = %q, want peer write applied", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySkipsDeleteOlderThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
|
||||
|
||||
if err := f.Replay(deleteEvent("/dir", "file", now.Add(-time.Minute).UnixNano())); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err != nil {
|
||||
t.Fatalf("newer local entry must survive a stale delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAppliesNewerDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now.Add(-time.Minute)}}
|
||||
|
||||
if err := f.Replay(deleteEvent("/dir", "file", now.UnixNano())); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("newer delete must remove the local entry")
|
||||
}
|
||||
}
|
||||
|
||||
// A move event touches two paths; the local check applies to each side
|
||||
// independently, so a newer destination is kept while the source is removed.
|
||||
func TestReplayMoveChecksEachPathSeparately(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
oldPath := util.NewFullPath("/a", "file")
|
||||
newPath := util.NewFullPath("/b", "file")
|
||||
store.entries[string(oldPath)] = &Entry{FullPath: oldPath, Attr: Attr{Mtime: now.Add(-time.Minute)}}
|
||||
store.entries[string(newPath)] = &Entry{FullPath: newPath, Attr: Attr{Mtime: now}, Content: []byte("kept")}
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/a",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "file"},
|
||||
NewEntry: &filer_pb.Entry{Name: "file"},
|
||||
NewParentPath: "/b",
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), oldPath); err == nil {
|
||||
t.Fatal("old source entry should be deleted")
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "kept" {
|
||||
t.Fatalf("content = %q, want newer destination kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A local delete leaves a recorded tombstone, so a replayed create older than
|
||||
// the delete must not resurrect the path even though no entry remains to
|
||||
// compare against.
|
||||
func TestReplaySkipsCreateOlderThanLocalDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
// The delete removed the entry; its commit record survives.
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a locally deleted entry")
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted directory covers its children: a bulk drop emits no per-child
|
||||
// events, so the tombstone on the ancestor is what a replayed child create
|
||||
// must check.
|
||||
func TestReplaySkipsCreateUnderDeletedAncestor(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
|
||||
f.notePathChange("/dir", now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "child", Content: []byte("stale")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), "/dir/child"); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a child of a deleted directory")
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded commit timestamp orders replay, not the entry's mtime: a rename
|
||||
// keeps the source's mtime on the destination, and a mounted client can set
|
||||
// mtime arbitrarily, so an entry whose mtime postdates the event must still
|
||||
// be overwritten when its commit record is older.
|
||||
func TestReplayAppliesWhenCommitRecordIsOlderThanFutureMtime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/b", "x")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set or kept from a rename source
|
||||
Content: []byte("old destination"),
|
||||
}
|
||||
// The last committed change predates the event.
|
||||
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/a",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "x"},
|
||||
NewEntry: &filer_pb.Entry{Name: "x", Content: []byte("renamed in")},
|
||||
NewParentPath: "/b",
|
||||
},
|
||||
TsNs: now.UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "renamed in" {
|
||||
t.Fatalf("content = %q, want the rename destination applied", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A store read failure is not a not-found: the replayed change must not be
|
||||
// applied on the guess that no newer local entry exists.
|
||||
func TestReplayPropagatesLookupError(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.findErr = errors.New("store read failed")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
err := f.Replay(overwriteEvent("/dir", "file", time.Now().UnixNano(), "v"))
|
||||
if err == nil {
|
||||
t.Fatal("expected Replay to propagate the FindEntry failure")
|
||||
}
|
||||
}
|
||||
|
||||
// The check and the mutation share the per-path entry lock, so a local write
|
||||
// holding that lock cannot be split by a replayed change.
|
||||
func TestReplaySerializesWithEntryLock(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
|
||||
|
||||
// A local mutation holding the path lock; a replay started behind it
|
||||
// sees the recorded commit and skips.
|
||||
held := f.EntryLockTable.AcquireLock("test local write", path, util.ExclusiveLock)
|
||||
f.notePathChange(path, now.UnixNano(), false)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale"))
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("replay finished while the path lock was still held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
f.EntryLockTable.ReleaseLock(path, held)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if entry.Content != nil {
|
||||
t.Fatalf("content = %q, want local entry kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A closed stop channel must abort the retry loop with
|
||||
// errPeerSubscriptionStopped so the subscription tears down and the offset
|
||||
// save keeps the last completed event instead of skipping this one.
|
||||
func TestReplicateMetadataChangeAbortsOnStop(t *testing.T) {
|
||||
store := &flakyReplayStore{
|
||||
stubFilerStore: newStubFilerStore(),
|
||||
failUntil: 1 << 30,
|
||||
err: errors.New("i/o timeout talking to store"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-stopped:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
close(stopChan)
|
||||
err := replicateMetadataChange(f, peer, quotaChangeEvent("stuck-bucket", 1<<20), stopChan)
|
||||
if !errors.Is(err, errPeerSubscriptionStopped) {
|
||||
t.Fatalf("err = %v, want errPeerSubscriptionStopped", err)
|
||||
}
|
||||
if got := store.attemptCount(); got != 0 {
|
||||
t.Fatalf("attempts = %d, want 0: a stopped subscription must not replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit-record keys must stay small: SQL KV stores keep the key suffix in a
|
||||
// VARCHAR(766) column, so a raw path over ~578 bytes silently drops the
|
||||
// record (the insert fails and the fallback update touches no row).
|
||||
func TestMetaPathKeyFixedSizeForLongPaths(t *testing.T) {
|
||||
path := util.NewFullPath("/", string(make([]rune, 600)))
|
||||
for _, p := range []util.FullPath{"/a", path} {
|
||||
if got := len(metaPathKey(metaPathTsPrefix, p)); got != len(metaPathTsPrefix)+16 {
|
||||
t.Fatalf("metaPathKey(%d-byte path) = %d bytes, want %d", len(p), got, len(metaPathTsPrefix)+16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact-path locking alone lets a replayed child create slip between a local
|
||||
// ancestor delete's tombstone check and the sweep: replay locks every
|
||||
// ancestor too, so it waits for the delete's lock and then sees the
|
||||
// tombstone.
|
||||
func TestReplaySerializesWithAncestorLock(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
now := time.Now()
|
||||
child := util.NewFullPath("/dir", "file")
|
||||
held := f.EntryLockTable.AcquireLock("test local delete", "/dir", util.ExclusiveLock)
|
||||
f.notePathChange("/dir", now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- f.Replay(createEvent)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("replay finished while the ancestor lock was still held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
f.EntryLockTable.ReleaseLock("/dir", held)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), child); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a child of a locally deleted directory")
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap ordering uses the sender's commit timestamp: a local commit
|
||||
// newer than the traversed entry keeps its state even when the peer entry's
|
||||
// mtime is far in the future.
|
||||
func TestBootstrapSkipsPeerEntryBehindLocalDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // client-settable, must not win
|
||||
Content: []byte("stale peer entry"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.Add(-time.Minute).UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("bootstrap resurrected a path newer local delete covered")
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards: a peer entry committed recently but stamped with an
|
||||
// old client mtime must still apply over an absent-or-older local commit.
|
||||
func TestBootstrapAppliesPeerCommitNewerThanLocal(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)}, // old client-set mtime
|
||||
Content: []byte("fresh peer write"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
got, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("peer entry with newer commit must bootstrap: %v", err)
|
||||
}
|
||||
if string(got.Content) != "fresh peer write" {
|
||||
t.Fatalf("content = %q, want peer entry applied", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A bootstrap-applied entry records the sender's commit timestamp, so a
|
||||
// replayed event older than it — but newer than the entry's mtime — is
|
||||
// still skipped.
|
||||
func TestBootstrapAppliedCommitBlocksStaleReplay(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)},
|
||||
Content: []byte("fresh peer write"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale replay")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
got, _ := store.FindEntry(context.Background(), path)
|
||||
if string(got.Content) != "fresh peer write" {
|
||||
t.Fatalf("content = %q, stale replay must not overwrite the bootstrap entry", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// An existing local entry is replaced only when the peer commit is newer —
|
||||
// not when its mtime happens to be.
|
||||
func TestBootstrapUpdatesExistingByCommitTime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set
|
||||
Content: []byte("old local write"),
|
||||
}
|
||||
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)},
|
||||
Content: []byte("newer peer commit"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
got, _ := store.FindEntry(context.Background(), path)
|
||||
if string(got.Content) != "newer peer commit" {
|
||||
t.Fatalf("content = %q, newer peer commit must update despite the future local mtime", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete whose commit record failed to persist still blocks a stale
|
||||
// replayed create: the pending record stands in until the retry lands it.
|
||||
func TestPendingDeleteRecordBlocksStaleReplay(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
// The delete already committed; only its ordering record failed.
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("stale create resurrected a path whose delete record is still pending")
|
||||
}
|
||||
}
|
||||
|
||||
// The failed record write retries until it lands, then leaves the pending set.
|
||||
func TestPendingCommitRecordRepairs(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f.notePathChange(path, 100, true)
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
t.Fatal("failed record write must stay pending")
|
||||
}
|
||||
store.setKvPutErr(nil)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path))
|
||||
if err != nil || !found || ts != 100 {
|
||||
t.Fatalf("delete record after repair: ts=%v found=%v err=%v", ts, found, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("pending record was not repaired")
|
||||
}
|
||||
|
||||
// CommitTsNs reports the recorded commit time over the entry mtime, and a
|
||||
// still-pending record over both.
|
||||
func TestCommitTsNsPrefersRecordAndPending(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 7 {
|
||||
t.Fatalf("no record: CommitTsNs = %d, %v; want fallback 7", got, err)
|
||||
}
|
||||
f.notePathChange(path, 50, false)
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 50 {
|
||||
t.Fatalf("recorded: CommitTsNs = %d, %v; want 50", got, err)
|
||||
}
|
||||
pending := &metaTsPendingRec{}
|
||||
pending.changeTsNs.Store(80)
|
||||
f.metaTsPending.Store(string(path), pending)
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 80 {
|
||||
t.Fatalf("pending: CommitTsNs = %d, %v; want 80", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A successful write discharges what the pending record owed before it, so a
|
||||
// repair pass can never put an older timestamp back over it.
|
||||
func TestPendingCommitRecordDischargedByNewerWrite(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f.notePathChange(path, 100, false)
|
||||
store.setKvPutErr(nil)
|
||||
f.notePathChange(path, 200, false)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil || !found || ts != 200 {
|
||||
t.Fatalf("MpTs after repair = %v, %v, %v; want 200", ts, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed delete record keeps its own time even when a later failed change
|
||||
// merges into the pending record: the repair must not stamp the delete at
|
||||
// the newer change's time.
|
||||
func TestPendingDeleteRecordKeepsItsTime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
f.notePathChange(path, 100, true)
|
||||
f.notePathChange(path, 200, false)
|
||||
store.setKvPutErr(nil)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path)); err != nil || !found || ts != 200 {
|
||||
t.Fatalf("MpTs = %v, %v, %v; want 200", ts, found, err)
|
||||
}
|
||||
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path)); err != nil || !found || ts != 100 {
|
||||
t.Fatalf("MpDl = %v, %v, %v; want 100", ts, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +34,55 @@ func TestOnPeerUpdateRepeatedAdd(t *testing.T) {
|
||||
t.Fatal("expecting the subscription to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown must close every peer's stopChan and wait for the loops, which is
|
||||
// what lets each loop's deferred offset save run before the store closes.
|
||||
func TestMetaAggregatorShutdownStopsPeerLoops(t *testing.T) {
|
||||
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
peer := pb.ServerAddress("127.0.0.1:1")
|
||||
ma.peerChans[peer] = stopChan
|
||||
ma.peerWg.Add(1)
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer ma.peerWg.Done()
|
||||
<-stopChan
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ma.Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Shutdown did not return")
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
default:
|
||||
t.Fatal("peer loop was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// After Shutdown begins, a peer-add update arriving on the still-connected
|
||||
// master watch must not start a subscription: it would have no stop signal
|
||||
// and could run past the store close.
|
||||
func TestOnPeerUpdateAfterShutdownRejected(t *testing.T) {
|
||||
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
|
||||
ma.Shutdown()
|
||||
|
||||
peer := pb.ServerAddress("127.0.0.1:1")
|
||||
ma.OnPeerUpdate(&master_pb.ClusterNodeUpdate{Address: string(peer), IsAdd: true}, time.Now())
|
||||
|
||||
ma.peerChansLock.Lock()
|
||||
_, found := ma.peerChans[peer]
|
||||
ma.peerChansLock.Unlock()
|
||||
if found {
|
||||
t.Fatal("peer subscription started after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// metaPathTsPrefix keys per-path commit timestamps in the filer store KV:
|
||||
// the newest event timestamp this filer committed or applied for the
|
||||
// path. Written for every emitted mutation event and kept after the entry
|
||||
// is deleted, so a replayed peer event can still be ordered against a
|
||||
// change that left no entry behind.
|
||||
metaPathTsPrefix = "MpTs"
|
||||
// metaPathDelPrefix keys delete-only timestamps. A replayed event checks
|
||||
// the path's ancestors too, so a directory delete also covers children
|
||||
// swept without per-child events (a dropped bucket's whole-store delete,
|
||||
// or a walk aborted partway).
|
||||
metaPathDelPrefix = "MpDl"
|
||||
)
|
||||
|
||||
// metaPathKey hashes the path into a fixed-size key. SQL-backed KV stores
|
||||
// split the key at byte 8 and store the remainder base64-encoded in a
|
||||
// VARCHAR(766) name column: a raw path over ~578 bytes overflows it, the
|
||||
// KvPut insert fails, and the fallback update touches no row yet returns no
|
||||
// error — the commit record would never land and stale replay could restore
|
||||
// the path.
|
||||
func metaPathKey(prefix string, path util.FullPath) []byte {
|
||||
sum := sha256.Sum256([]byte(path))
|
||||
key := make([]byte, len(prefix)+16)
|
||||
copy(key, prefix)
|
||||
copy(key[len(prefix):], sum[:16])
|
||||
return key
|
||||
}
|
||||
|
||||
// kvReadTsNs returns the timestamp recorded for key, or found=false when the
|
||||
// key is absent or malformed. Any other store read failure is an error: a
|
||||
// caller deciding whether a replayed change is stale must not guess.
|
||||
func (f *Filer) kvReadTsNs(ctx context.Context, key []byte) (tsNs int64, found bool, err error) {
|
||||
value, err := f.Store.KvGet(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrKvNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if len(value) < 8 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(util.BytesToUint64(value)), true, nil
|
||||
}
|
||||
|
||||
// notePathChange persists tsNs as path's newest committed change. Local event
|
||||
// timestamps are stamped monotonically (metaLogInflight.stamp), so a plain
|
||||
// put cannot regress an earlier local record; a higher peer record it may
|
||||
// overwrite was already newer under last-writer-wins ordering anyway. A
|
||||
// delete additionally writes a delete record the ancestor lookup consults.
|
||||
//
|
||||
// The write uses a fresh context: callers notify after CommitTransaction,
|
||||
// and SQL stores keep the already-committed *sql.Tx in the request context —
|
||||
// a record written through it would fail and the path would lose ordering.
|
||||
//
|
||||
// A failed write is kept in metaTsPending and retried in the background: the
|
||||
// mutation the record orders has already committed, so dropping it would let
|
||||
// a stale replayed change overwrite or resurrect the path while the store is
|
||||
// unreachable. Ordering reads consult the pending record until the retry
|
||||
// lands it.
|
||||
func (f *Filer) notePathChange(path util.FullPath, tsNs int64, deleted bool) {
|
||||
if f.Store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := string(path)
|
||||
rec := f.pendingRec(key)
|
||||
defer rec.mu.Unlock()
|
||||
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if err := f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); err != nil {
|
||||
glog.WarningfCtx(ctx, "record meta change time %s: %v", path, err)
|
||||
if tsNs > rec.changeTsNs.Load() {
|
||||
rec.changeTsNs.Store(tsNs)
|
||||
}
|
||||
} else if rec.changeTsNs.Load() <= tsNs {
|
||||
// This write covers every change record owed so far.
|
||||
rec.changeTsNs.Store(0)
|
||||
}
|
||||
if deleted {
|
||||
if err := f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); err != nil {
|
||||
glog.WarningfCtx(ctx, "record meta delete time %s: %v", path, err)
|
||||
if tsNs > rec.deleteTsNs.Load() {
|
||||
rec.deleteTsNs.Store(tsNs)
|
||||
}
|
||||
} else if rec.deleteTsNs.Load() <= tsNs {
|
||||
rec.deleteTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
if rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0 {
|
||||
f.metaTsPending.CompareAndDelete(key, rec)
|
||||
return
|
||||
}
|
||||
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
|
||||
go f.repairMetaPathTs(path)
|
||||
}
|
||||
}
|
||||
|
||||
// metaTsPendingRec is a commit record still on its way to the store, holding
|
||||
// only work not yet written: changeTsNs is the newest committed change the
|
||||
// MpTs record lacks and deleteTsNs the newest delete the MpDl record lacks —
|
||||
// the two keep their own times so a later update cannot masquerade as the
|
||||
// delete. Store writes and the bookkeeping they discharge share mu, so a
|
||||
// repair pass can never write an older record over a newer one that already
|
||||
// landed; readers take the atomic fields directly.
|
||||
type metaTsPendingRec struct {
|
||||
mu sync.Mutex
|
||||
changeTsNs atomic.Int64
|
||||
deleteTsNs atomic.Int64
|
||||
}
|
||||
|
||||
// pendingRec returns the live pending record for key, locked for mutation,
|
||||
// recreating it when a repair already dropped the previous one.
|
||||
func (f *Filer) pendingRec(key string) *metaTsPendingRec {
|
||||
for {
|
||||
cur, _ := f.metaTsPending.LoadOrStore(key, &metaTsPendingRec{})
|
||||
rec := cur.(*metaTsPendingRec)
|
||||
rec.mu.Lock()
|
||||
if live, ok := f.metaTsPending.Load(key); ok && live == cur {
|
||||
return rec
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// repairMetaPathTs retries a pending commit record until it lands. Each pass
|
||||
// writes only what the record still owes, so a merge by a later failure is
|
||||
// picked up without another goroutine and a landed write is never rewritten.
|
||||
// The retry is unbounded — the mutation is already committed and only the
|
||||
// record preserves its ordering.
|
||||
func (f *Filer) repairMetaPathTs(path util.FullPath) {
|
||||
key := string(path)
|
||||
ctx := f.flushCtx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
defer func() {
|
||||
f.metaTsRepairing.Delete(key)
|
||||
// A failure landing between the last pass and the flag release
|
||||
// still needs a repair pass — unless the retry context is done.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, ok := f.metaTsPending.Load(key); ok {
|
||||
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
|
||||
go f.repairMetaPathTs(path)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
if _, ok := f.metaTsPending.Load(key); !ok {
|
||||
return
|
||||
}
|
||||
rec := f.pendingRec(key)
|
||||
var tsErr, delErr error
|
||||
if tsNs := rec.changeTsNs.Load(); tsNs > 0 {
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if tsErr = f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); tsErr == nil {
|
||||
rec.changeTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
if tsNs := rec.deleteTsNs.Load(); tsNs > 0 {
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if delErr = f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); delErr == nil {
|
||||
rec.deleteTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
done := rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0
|
||||
if done {
|
||||
f.metaTsPending.CompareAndDelete(key, rec)
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
if !done {
|
||||
glog.V(0).InfofCtx(ctx, "retry record meta change time %s: %v %v", path, tsErr, delErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pendingPathTsNs returns the newest pending commit timestamp for path and
|
||||
// the newest pending delete timestamp.
|
||||
func (f *Filer) pendingPathTsNs(path util.FullPath) (changeTsNs, deleteTsNs int64) {
|
||||
cur, ok := f.metaTsPending.Load(string(path))
|
||||
if !ok {
|
||||
return 0, 0
|
||||
}
|
||||
rec := cur.(*metaTsPendingRec)
|
||||
return rec.changeTsNs.Load(), rec.deleteTsNs.Load()
|
||||
}
|
||||
|
||||
// CommitTsNs returns the newest commit timestamp known for path — the
|
||||
// recorded event time, or a still-pending one — and fallbackTsNs when none
|
||||
// exists. Peers stamp traversal entries with it so a bootstrap subscriber
|
||||
// orders each entry on the sender's commit clock rather than the entry's
|
||||
// client-settable mtime. A store read failure is an error, not a fallback:
|
||||
// the mtime it would mint could outdate real commits.
|
||||
func (f *Filer) CommitTsNs(ctx context.Context, path util.FullPath, fallbackTsNs int64) (int64, error) {
|
||||
if f.Store == nil {
|
||||
return fallbackTsNs, nil
|
||||
}
|
||||
tsNs := fallbackTsNs
|
||||
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
tsNs = recTs
|
||||
}
|
||||
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
|
||||
tsNs = pendTs
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
|
||||
// noteCommittedEvent records the event's commit timestamp for every path it
|
||||
// touches: the source of a delete or rename, and the target of a create,
|
||||
// update, or rename. It runs after the local mutation commits while the
|
||||
// caller still holds the path's entry lock (for filer-server mutations), so
|
||||
// a peer replay ordered by the same lock sees either the whole local change
|
||||
// or none of it.
|
||||
func (f *Filer) noteCommittedEvent(event *filer_pb.SubscribeMetadataResponse) {
|
||||
message := event.EventNotification
|
||||
if message == nil {
|
||||
return
|
||||
}
|
||||
var source, target util.FullPath
|
||||
if message.OldEntry != nil {
|
||||
source = util.FullPath(filer_pb.MetadataEventSourceFullPath(event))
|
||||
}
|
||||
if message.NewEntry != nil {
|
||||
target = util.FullPath(filer_pb.MetadataEventTargetFullPath(event))
|
||||
}
|
||||
// A delete or a rename removes the source path; an update reports the
|
||||
// same path on both sides and counts once.
|
||||
if source != "" && source != target {
|
||||
f.notePathChange(source, event.TsNs, true)
|
||||
}
|
||||
if target != "" {
|
||||
f.notePathChange(target, event.TsNs, false)
|
||||
}
|
||||
}
|
||||
|
||||
// deletedAfterTsNs returns the newest recorded delete timestamp among path
|
||||
// and its ancestors.
|
||||
func (f *Filer) deletedAfterTsNs(ctx context.Context, path util.FullPath) (int64, error) {
|
||||
var tsNs int64
|
||||
for p := string(path); p != "" && p != "/"; {
|
||||
ts, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathDelPrefix, util.FullPath(p)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found && ts > tsNs {
|
||||
tsNs = ts
|
||||
}
|
||||
if _, pendDel := f.pendingPathTsNs(util.FullPath(p)); pendDel > tsNs {
|
||||
tsNs = pendDel
|
||||
}
|
||||
i := strings.LastIndexByte(p, '/')
|
||||
if i <= 0 {
|
||||
break
|
||||
}
|
||||
p = p[:i]
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
|
||||
// localCommitTsNs returns the newest timestamp this filer is known to have
|
||||
// committed for path: the recorded event time when one exists — it is not
|
||||
// client-controllable the way an entry's mtime is — else the entry's mtime
|
||||
// for entries written before recording existed, plus any recorded delete of
|
||||
// the path or an ancestor.
|
||||
func (f *Filer) localCommitTsNs(ctx context.Context, path util.FullPath) (int64, error) {
|
||||
if f.Store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var tsNs int64
|
||||
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
tsNs = recTs
|
||||
} else if entry, findErr := f.Store.FindEntry(ctx, path); findErr != nil {
|
||||
if !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
return 0, findErr
|
||||
}
|
||||
} else if entry != nil {
|
||||
tsNs = entry.Mtime.UnixNano()
|
||||
}
|
||||
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
|
||||
tsNs = pendTs
|
||||
}
|
||||
delTs, err := f.deletedAfterTsNs(ctx, path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delTs > tsNs {
|
||||
tsNs = delTs
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
+151
-14
@@ -2,6 +2,8 @@ package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -9,31 +11,166 @@ import (
|
||||
)
|
||||
|
||||
// Replay applies the delete and the insert as separate, non-transactional
|
||||
// store calls, so retrying it after a partial failure is not atomic.
|
||||
func Replay(filerStore FilerStore, resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
// store calls, so retrying it after a partial failure is not atomic; both
|
||||
// operations are idempotent and the timestamp check tolerates re-delivery.
|
||||
//
|
||||
// A replayed event can be older than local state: a peer resuming from a
|
||||
// stale offset replays everything since the offset, and two writes through
|
||||
// different filers race within the replication lag. Apply each path's change
|
||||
// only when no newer local change is recorded, so a late event cannot revert
|
||||
// or resurrect entries.
|
||||
//
|
||||
// Ordering uses the per-path commit timestamp recorded for every emitted
|
||||
// mutation (meta_path_ts.go), which survives deletes as a tombstone and is
|
||||
// not client-controllable the way entry mtime is — a mounted client can set
|
||||
// mtime into the future, and a rename keeps the source file's mtime on the
|
||||
// destination entry. Entries written before recording existed fall back to
|
||||
// their mtime.
|
||||
//
|
||||
// The check and the mutation run under the same per-path entry lock the
|
||||
// filer server's local writes hold, so they cannot be split by a concurrent
|
||||
// local update or by another peer's replay.
|
||||
func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
message := resp.EventNotification
|
||||
var oldPath util.FullPath
|
||||
var newEntry *Entry
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
var oldPath, newPath util.FullPath
|
||||
if message.OldEntry != nil {
|
||||
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := filerStore.DeleteEntry(context.Background(), oldPath); err != nil {
|
||||
}
|
||||
if message.NewEntry != nil {
|
||||
newPath = util.NewFullPath(dir, message.NewEntry.Name)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
unlock := f.lockReplayPaths(oldPath, newPath)
|
||||
defer unlock()
|
||||
|
||||
samePath := oldPath != "" && oldPath == newPath
|
||||
if samePath {
|
||||
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
|
||||
if err != nil || newer {
|
||||
return err
|
||||
}
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record the commit before the insert too: if the insert fails and
|
||||
// the event is retried later, the path sits deleted in between —
|
||||
// without the record a stale peer create could resurrect it in the
|
||||
// gap. The entry still exists afterward, so no delete record.
|
||||
f.notePathChange(oldPath, resp.TsNs, false)
|
||||
glog.V(4).Infof("creating %v", newPath)
|
||||
newEntry := FromPbEntry(dir, message.NewEntry)
|
||||
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
f.notePathChange(oldPath, resp.TsNs, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
if message.OldEntry != nil {
|
||||
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newer {
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
f.notePathChange(oldPath, resp.TsNs, true)
|
||||
}
|
||||
}
|
||||
|
||||
if message.NewEntry != nil {
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
key := util.NewFullPath(dir, message.NewEntry.Name)
|
||||
glog.V(4).Infof("creating %v", key)
|
||||
newEntry = FromPbEntry(dir, message.NewEntry)
|
||||
if err := filerStore.InsertEntry(context.Background(), newEntry); err != nil {
|
||||
newer, err := f.replayBlockedByLocal(ctx, newPath, resp.TsNs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newer {
|
||||
glog.V(4).Infof("creating %v", newPath)
|
||||
newEntry := FromPbEntry(dir, message.NewEntry)
|
||||
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
f.notePathChange(newPath, resp.TsNs, false)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// replayBlockedByLocal reports whether the local store already holds a
|
||||
// version of path committed after the event's timestamp. Lookup failures are
|
||||
// propagated: a replayed change must not be applied on the guess that the
|
||||
// path was simply absent, or a transient read error could let a stale event
|
||||
// overwrite a newer entry past the point of no return.
|
||||
func (f *Filer) replayBlockedByLocal(ctx context.Context, path util.FullPath, tsNs int64) (bool, error) {
|
||||
if tsNs <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
localTsNs, err := f.localCommitTsNs(ctx, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return localTsNs > tsNs, nil
|
||||
}
|
||||
|
||||
// lockReplayPaths holds the shared entry locks for every path the event
|
||||
// touches, in the same sorted order the rename handler takes them.
|
||||
// lockReplayPaths takes the per-path entry locks for the event's paths and
|
||||
// every ancestor of them, in lexicographic order — parents sort before
|
||||
// children, the same ordering the local rename handler's two-path lock uses,
|
||||
// so lock acquisition is deadlock-free across operations. Ancestors are
|
||||
// included because the replay check consults ancestor delete records: a
|
||||
// local recursive delete or directory rename holds only the ancestor's lock,
|
||||
// and without contending on it a replay could reinsert a stale child after
|
||||
// its check. Ancestors are locked shared — they only need to exclude an
|
||||
// exclusive holder, not each other, or every replay under a busy parent
|
||||
// would serialize — while a path the event actually changes stays exclusive
|
||||
// even when it is also the other path's ancestor. The root is never locked:
|
||||
// it cannot be deleted, and a shared root lock would still be excluded by
|
||||
// every top-level delete.
|
||||
func (f *Filer) lockReplayPaths(oldPath, newPath util.FullPath) func() {
|
||||
lt := f.EntryLockTable
|
||||
if lt == nil {
|
||||
return func() {}
|
||||
}
|
||||
var paths []util.FullPath
|
||||
addAncestors := func(p util.FullPath) {
|
||||
for i := strings.LastIndexByte(string(p), '/'); i > 0; i = strings.LastIndexByte(string(p), '/') {
|
||||
p = p[:i]
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
addAncestors(oldPath)
|
||||
addAncestors(newPath)
|
||||
if oldPath != "" {
|
||||
paths = append(paths, oldPath)
|
||||
}
|
||||
if newPath != "" {
|
||||
paths = append(paths, newPath)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return func() {}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
paths = slices.Compact(paths)
|
||||
locks := make([]*util.ActiveLock, len(paths))
|
||||
for i, p := range paths {
|
||||
lockType := util.SharedLock
|
||||
if p == oldPath || p == newPath {
|
||||
lockType = util.ExclusiveLock
|
||||
}
|
||||
locks[i] = lt.AcquireLock("MetaReplay", p, lockType)
|
||||
}
|
||||
return func() {
|
||||
for i := len(paths) - 1; i >= 0; i-- {
|
||||
lt.ReleaseLock(paths[i], locks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
// A sequential reader that consumes a chunk in several small ReadAt calls, as
|
||||
// the S3 GET path does with its 256 KiB copy buffer over 4 MiB chunks, must
|
||||
// keep its read-ahead at prefetchCount chunks. Each ReadAt triggers a
|
||||
// prefetch; if chunks already being fetched are not counted toward the
|
||||
// window, every call starts prefetchCount more downloads and the read-ahead
|
||||
// runs to the ReaderCache downloader limit, buffering most of the object.
|
||||
func TestChunkReadAtSequentialPrefetchStaysWithinWindow(t *testing.T) {
|
||||
const chunkSize = 64 << 10
|
||||
const chunkCount = 64
|
||||
const sliceSize = 16 << 10
|
||||
|
||||
var readerChunk atomic.Int64
|
||||
var maxLead atomic.Int64
|
||||
rc := NewReaderCache(256, (*chunk_cache.TieredChunkCache)(nil), func(context.Context, string) ([]string, error) {
|
||||
return []string{"unused"}, nil
|
||||
}, nil)
|
||||
defer rc.destroy()
|
||||
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
|
||||
idx, err := strconv.Atoi(strings.TrimPrefix(fileId, "chunk"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lead := int64(idx) - readerChunk.Load()
|
||||
for {
|
||||
cur := maxLead.Load()
|
||||
if lead <= cur || maxLead.CompareAndSwap(cur, lead) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
views := NewIntervalList[*ChunkView]()
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
views.AppendInterval(&Interval[*ChunkView]{
|
||||
StartOffset: int64(i * chunkSize),
|
||||
StopOffset: int64((i + 1) * chunkSize),
|
||||
Value: &ChunkView{
|
||||
FileId: fmt.Sprintf("chunk%d", i),
|
||||
ViewSize: chunkSize,
|
||||
ViewOffset: int64(i * chunkSize),
|
||||
ChunkSize: chunkSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
reader := NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, DefaultPrefetchCount)
|
||||
defer reader.ReleaseStream()
|
||||
|
||||
buf := make([]byte, sliceSize)
|
||||
for off := int64(0); off < chunkSize*chunkCount; off += sliceSize {
|
||||
readerChunk.Store(off / chunkSize)
|
||||
if _, err := reader.ReadAt(buf, off); err != nil && off+sliceSize < chunkSize*chunkCount {
|
||||
t.Fatalf("ReadAt(%d): %v", off, err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := maxLead.Load(); got > DefaultPrefetchCount {
|
||||
t.Fatalf("read-ahead reached %d chunks past the reader, want at most %d", got, DefaultPrefetchCount)
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,10 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
|
||||
return
|
||||
}
|
||||
|
||||
cached := 0
|
||||
for x := chunkViews; x != nil && cached < count; x = x.Next {
|
||||
// The window is the next count chunks, whether or not a download for them
|
||||
// is already running: counting only newly started downloads would push the
|
||||
// window count chunks further on every call.
|
||||
for x, i := chunkViews, 0; x != nil && i < count; x, i = x.Next, i+1 {
|
||||
chunkView := x.Value
|
||||
if _, found := rc.downloaders[chunkView.FileId]; found {
|
||||
continue
|
||||
@@ -124,7 +126,6 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
|
||||
rc.downloaders[chunkView.FileId] = cacher
|
||||
go cacher.startCaching()
|
||||
started = append(started, cacher)
|
||||
cached++
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -714,6 +714,10 @@ message TraverseBfsMetadataRequest {
|
||||
message TraverseBfsMetadataResponse {
|
||||
string directory = 1;
|
||||
Entry entry = 2;
|
||||
// The sender's per-path commit timestamp (see meta_path_ts.go): the newest
|
||||
// event time this filer committed for the path, 0 when unrecorded so
|
||||
// subscribers fall back to the entry's mtime.
|
||||
int64 entry_ts_ns = 3;
|
||||
}
|
||||
|
||||
message LogEntry {
|
||||
|
||||
@@ -4777,9 +4777,13 @@ func (x *TraverseBfsMetadataRequest) GetExcludedPrefixes() []string {
|
||||
}
|
||||
|
||||
type TraverseBfsMetadataResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
|
||||
Entry *Entry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
|
||||
Entry *Entry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
// The sender's per-path commit timestamp (see meta_path_ts.go): the newest
|
||||
// event time this filer committed for the path, 0 when unrecorded so
|
||||
// subscribers fall back to the entry's mtime.
|
||||
EntryTsNs int64 `protobuf:"varint,3,opt,name=entry_ts_ns,json=entryTsNs,proto3" json:"entry_ts_ns,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4828,6 +4832,13 @@ func (x *TraverseBfsMetadataResponse) GetEntry() *Entry {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TraverseBfsMetadataResponse) GetEntryTsNs() int64 {
|
||||
if x != nil {
|
||||
return x.EntryTsNs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TsNs int64 `protobuf:"varint,1,opt,name=ts_ns,json=tsNs,proto3" json:"ts_ns,omitempty"`
|
||||
@@ -7688,10 +7699,11 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\bfiler_id\x18\x03 \x01(\tR\afilerId\"g\n" +
|
||||
"\x1aTraverseBfsMetadataRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12+\n" +
|
||||
"\x11excluded_prefixes\x18\x02 \x03(\tR\x10excludedPrefixes\"b\n" +
|
||||
"\x11excluded_prefixes\x18\x02 \x03(\tR\x10excludedPrefixes\"\x82\x01\n" +
|
||||
"\x1bTraverseBfsMetadataResponse\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12%\n" +
|
||||
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\x8b\x01\n" +
|
||||
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x12\x1e\n" +
|
||||
"\ventry_ts_ns\x18\x03 \x01(\x03R\tentryTsNs\"\x8b\x01\n" +
|
||||
"\bLogEntry\x12\x13\n" +
|
||||
"\x05ts_ns\x18\x01 \x01(\x03R\x04tsNs\x12,\n" +
|
||||
"\x12partition_key_hash\x18\x02 \x01(\x05R\x10partitionKeyHash\x12\x12\n" +
|
||||
|
||||
@@ -4379,6 +4379,11 @@ func (m *TraverseBfsMetadataResponse) MarshalToSizedBufferVT(dAtA []byte) (int,
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if m.EntryTsNs != 0 {
|
||||
i = protohelpers.EncodeVarint(dAtA, i, uint64(m.EntryTsNs))
|
||||
i--
|
||||
dAtA[i] = 0x18
|
||||
}
|
||||
if m.Entry != nil {
|
||||
size, err := m.Entry.MarshalToSizedBufferVT(dAtA[:i])
|
||||
if err != nil {
|
||||
@@ -8267,6 +8272,9 @@ func (m *TraverseBfsMetadataResponse) SizeVT() (n int) {
|
||||
l = m.Entry.SizeVT()
|
||||
n += 1 + l + protohelpers.SizeOfVarint(uint64(l))
|
||||
}
|
||||
if m.EntryTsNs != 0 {
|
||||
n += 1 + protohelpers.SizeOfVarint(uint64(m.EntryTsNs))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
@@ -20865,6 +20873,25 @@ func (m *TraverseBfsMetadataResponse) UnmarshalVT(dAtA []byte) error {
|
||||
return err
|
||||
}
|
||||
iNdEx = postIndex
|
||||
case 3:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field EntryTsNs", wireType)
|
||||
}
|
||||
m.EntryTsNs = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return protohelpers.ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.EntryTsNs |= int64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := protohelpers.Skip(dAtA[iNdEx:])
|
||||
|
||||
@@ -305,6 +305,11 @@ message VolumeGrowRequest {
|
||||
string data_node = 7;
|
||||
uint32 memory_map_max_size_mb = 8;
|
||||
string disk_type = 9;
|
||||
// If > 0, grow only when the average fill ratio of the layout's writable
|
||||
// volumes has reached this threshold (e.g. 0.6). Lets callers pre-grow on
|
||||
// fill level with one call, instead of listing volumes to compute the
|
||||
// ratio themselves. Ignored when there are no writable volumes.
|
||||
float grow_avg_fill_threshold = 10;
|
||||
}
|
||||
|
||||
message AssignResponse {
|
||||
@@ -611,4 +616,9 @@ message RaftLeadershipTransferResponse {
|
||||
}
|
||||
|
||||
message VolumeGrowResponse {
|
||||
// Average fill ratio of the layout's writable volumes at request time
|
||||
// (0 when there are none). Reported whether or not growth was triggered.
|
||||
float avg_fill = 1;
|
||||
// Number of writable volumes at request time.
|
||||
uint32 writable_count = 2;
|
||||
}
|
||||
|
||||
@@ -1739,8 +1739,13 @@ type VolumeGrowRequest struct {
|
||||
DataNode string `protobuf:"bytes,7,opt,name=data_node,json=dataNode,proto3" json:"data_node,omitempty"`
|
||||
MemoryMapMaxSizeMb uint32 `protobuf:"varint,8,opt,name=memory_map_max_size_mb,json=memoryMapMaxSizeMb,proto3" json:"memory_map_max_size_mb,omitempty"`
|
||||
DiskType string `protobuf:"bytes,9,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// If > 0, grow only when the average fill ratio of the layout's writable
|
||||
// volumes has reached this threshold (e.g. 0.6). Lets callers pre-grow on
|
||||
// fill level with one call, instead of listing volumes to compute the
|
||||
// ratio themselves. Ignored when there are no writable volumes.
|
||||
GrowAvgFillThreshold float32 `protobuf:"fixed32,10,opt,name=grow_avg_fill_threshold,json=growAvgFillThreshold,proto3" json:"grow_avg_fill_threshold,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VolumeGrowRequest) Reset() {
|
||||
@@ -1836,6 +1841,13 @@ func (x *VolumeGrowRequest) GetDiskType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VolumeGrowRequest) GetGrowAvgFillThreshold() float32 {
|
||||
if x != nil {
|
||||
return x.GrowAvgFillThreshold
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type AssignResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Fid string `protobuf:"bytes,1,opt,name=fid,proto3" json:"fid,omitempty"`
|
||||
@@ -4659,7 +4671,12 @@ func (x *RaftLeadershipTransferResponse) GetNewLeader() string {
|
||||
}
|
||||
|
||||
type VolumeGrowResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Average fill ratio of the layout's writable volumes at request time
|
||||
// (0 when there are none). Reported whether or not growth was triggered.
|
||||
AvgFill float32 `protobuf:"fixed32,1,opt,name=avg_fill,json=avgFill,proto3" json:"avg_fill,omitempty"`
|
||||
// Number of writable volumes at request time.
|
||||
WritableCount uint32 `protobuf:"varint,2,opt,name=writable_count,json=writableCount,proto3" json:"writable_count,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4694,6 +4711,20 @@ func (*VolumeGrowResponse) Descriptor() ([]byte, []int) {
|
||||
return file_master_proto_rawDescGZIP(), []int{68}
|
||||
}
|
||||
|
||||
func (x *VolumeGrowResponse) GetAvgFill() float32 {
|
||||
if x != nil {
|
||||
return x.AvgFill
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *VolumeGrowResponse) GetWritableCount() uint32 {
|
||||
if x != nil {
|
||||
return x.WritableCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SuperBlockExtra_ErasureCoding struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Data uint32 `protobuf:"varint,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||
@@ -5229,7 +5260,7 @@ const file_master_proto_rawDesc = "" +
|
||||
"\x15writable_volume_count\x18\t \x01(\rR\x13writableVolumeCount\x12\x1b\n" +
|
||||
"\tdisk_type\x18\n" +
|
||||
" \x01(\tR\bdiskType\x12,\n" +
|
||||
"\x12expected_data_size\x18\v \x01(\x04R\x10expectedDataSize\"\xbe\x02\n" +
|
||||
"\x12expected_data_size\x18\v \x01(\x04R\x10expectedDataSize\"\xf5\x02\n" +
|
||||
"\x11VolumeGrowRequest\x122\n" +
|
||||
"\x15writable_volume_count\x18\x01 \x01(\rR\x13writableVolumeCount\x12 \n" +
|
||||
"\vreplication\x18\x02 \x01(\tR\vreplication\x12\x1e\n" +
|
||||
@@ -5242,7 +5273,9 @@ const file_master_proto_rawDesc = "" +
|
||||
"\x04rack\x18\x06 \x01(\tR\x04rack\x12\x1b\n" +
|
||||
"\tdata_node\x18\a \x01(\tR\bdataNode\x122\n" +
|
||||
"\x16memory_map_max_size_mb\x18\b \x01(\rR\x12memoryMapMaxSizeMb\x12\x1b\n" +
|
||||
"\tdisk_type\x18\t \x01(\tR\bdiskType\"\xc4\x01\n" +
|
||||
"\tdisk_type\x18\t \x01(\tR\bdiskType\x125\n" +
|
||||
"\x17grow_avg_fill_threshold\x18\n" +
|
||||
" \x01(\x02R\x14growAvgFillThreshold\"\xc4\x01\n" +
|
||||
"\x0eAssignResponse\x12\x10\n" +
|
||||
"\x03fid\x18\x01 \x01(\tR\x03fid\x12\x14\n" +
|
||||
"\x05count\x18\x04 \x01(\x04R\x05count\x12\x14\n" +
|
||||
@@ -5477,8 +5510,10 @@ const file_master_proto_rawDesc = "" +
|
||||
"\x1eRaftLeadershipTransferResponse\x12'\n" +
|
||||
"\x0fprevious_leader\x18\x01 \x01(\tR\x0epreviousLeader\x12\x1d\n" +
|
||||
"\n" +
|
||||
"new_leader\x18\x02 \x01(\tR\tnewLeader\"\x14\n" +
|
||||
"\x12VolumeGrowResponse2\xf1\x12\n" +
|
||||
"new_leader\x18\x02 \x01(\tR\tnewLeader\"V\n" +
|
||||
"\x12VolumeGrowResponse\x12\x19\n" +
|
||||
"\bavg_fill\x18\x01 \x01(\x02R\aavgFill\x12%\n" +
|
||||
"\x0ewritable_count\x18\x02 \x01(\rR\rwritableCount2\xf1\x12\n" +
|
||||
"\aSeaweed\x12I\n" +
|
||||
"\rSendHeartbeat\x12\x14.master_pb.Heartbeat\x1a\x1c.master_pb.HeartbeatResponse\"\x00(\x010\x01\x12X\n" +
|
||||
"\rKeepConnected\x12\x1f.master_pb.KeepConnectedRequest\x1a .master_pb.KeepConnectedResponse\"\x00(\x010\x01\x12Q\n" +
|
||||
|
||||
@@ -37,6 +37,9 @@ type HandlerBuildOptions struct {
|
||||
GrpcDialOption grpc.DialOption
|
||||
MaxExecute int
|
||||
WorkingDir string
|
||||
// WorkerAddress is the advertised weed worker -address (host:port).
|
||||
// It is not a volume-server URL; handlers match replicas by hostname.
|
||||
WorkerAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -204,6 +207,12 @@ func (w *Worker) runOnce(ctx context.Context, adminAddress string) error {
|
||||
connCtx, cancelConn := context.WithCancel(ctx)
|
||||
defer cancelConn()
|
||||
|
||||
if md := security.GrpcAdminPeerCredentials(
|
||||
security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key")),
|
||||
util.GetViper().GetInt("jwt.filer_signing.expires_after_seconds")); len(md) > 0 {
|
||||
connCtx = metadata.NewOutgoingContext(connCtx, md)
|
||||
}
|
||||
|
||||
stream, err := client.WorkerStream(connCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open worker stream: %w", err)
|
||||
|
||||
@@ -110,6 +110,14 @@ type RemoteStorageStreamReader interface {
|
||||
ReadFileAsStream(ctx context.Context, loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (reader io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
// RemoteStorageObjectCopier is an optional interface for remote storage clients
|
||||
// that copy an object to another key within the storage, without the content
|
||||
// passing through the client. A rename of an entry whose content exists only
|
||||
// on the remote needs it.
|
||||
type RemoteStorageObjectCopier interface {
|
||||
CopyFile(src *remote_pb.RemoteStorageLocation, dst *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error)
|
||||
}
|
||||
|
||||
// CacheWaitTimeout is how long a read of an uncached remote-only object waits
|
||||
// for the local cache before serving another way: small files wait longer since
|
||||
// their cache completes quickly, large files fail fast for better TTFB. A mount
|
||||
|
||||
@@ -610,7 +610,7 @@ func (s *s3RemoteStorageClient) UpdateFileMetadata(loc *remote_pb.RemoteStorageL
|
||||
copyInput := &s3.CopyObjectInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Key: aws.String(key),
|
||||
CopySource: aws.String(url.PathEscape(loc.Bucket + "/" + key)),
|
||||
CopySource: aws.String(s3CopySource(loc.Bucket, key)),
|
||||
MetadataDirective: aws.String(s3.MetadataDirectiveReplace),
|
||||
Metadata: headOut.Metadata,
|
||||
ContentType: headOut.ContentType,
|
||||
@@ -663,6 +663,168 @@ func (s *s3RemoteStorageClient) UpdateFileMetadata(loc *remote_pb.RemoteStorageL
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// s3CopyPartSize is the smallest part a multipart copy uses; parts grow so
|
||||
// the largest object stays within the 10000-part limit
|
||||
const s3CopyPartSize = 512 * 1024 * 1024
|
||||
|
||||
// CopyFile copies the object at src to dst on the server side, with a single
|
||||
// CopyObject up to the 5 GB limit and a multipart copy above it. The copy
|
||||
// keeps the source's metadata.
|
||||
func (s *s3RemoteStorageClient) CopyFile(src *remote_pb.RemoteStorageLocation, dst *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
srcKey, dstKey := src.Path[1:], dst.Path[1:]
|
||||
headOut, err := s.conn.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(src.Bucket),
|
||||
Key: aws.String(srcKey),
|
||||
})
|
||||
if err != nil {
|
||||
if reqErr, ok := err.(awserr.RequestFailure); ok && reqErr.StatusCode() == http.StatusNotFound {
|
||||
return nil, remote_storage.ErrRemoteObjectNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("stat s3 %s/%s before copy: %w", src.Bucket, srcKey, err)
|
||||
}
|
||||
copySource := aws.String(s3CopySource(src.Bucket, srcKey))
|
||||
var storageClass *string
|
||||
if s.conf.S3StorageClass != "" {
|
||||
storageClass = aws.String(s.conf.S3StorageClass)
|
||||
}
|
||||
|
||||
if size := aws.Int64Value(headOut.ContentLength); size <= s3CopyObjectSizeLimit {
|
||||
// a copy takes the bucket's default encryption unless told otherwise
|
||||
if _, err = s.conn.CopyObject(&s3.CopyObjectInput{
|
||||
Bucket: aws.String(dst.Bucket),
|
||||
Key: aws.String(dstKey),
|
||||
CopySource: copySource,
|
||||
StorageClass: storageClass,
|
||||
ServerSideEncryption: headOut.ServerSideEncryption,
|
||||
SSEKMSKeyId: headOut.SSEKMSKeyId,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("copy s3 %s/%s to %s/%s: %w", src.Bucket, srcKey, dst.Bucket, dstKey, err)
|
||||
}
|
||||
} else if err = s.multipartCopy(headOut, src, copySource, size, dst.Bucket, dstKey, storageClass); err != nil {
|
||||
return nil, fmt.Errorf("copy s3 %s/%s to %s/%s: %w", src.Bucket, srcKey, dst.Bucket, dstKey, err)
|
||||
}
|
||||
|
||||
return s.readFileRemoteEntry(dst)
|
||||
}
|
||||
|
||||
// s3CopySource escapes each segment of the copy source and keeps the slashes,
|
||||
// which every S3 implementation reads back
|
||||
func s3CopySource(bucket, key string) string {
|
||||
segments := strings.Split(bucket+"/"+key, "/")
|
||||
for i, segment := range segments {
|
||||
segments[i] = url.PathEscape(segment)
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
// s3TaggingHeader encodes tags as the query string x-amz-tagging takes, with
|
||||
// spaces as %20: a + is read back as a space by some implementations and
|
||||
// literally by others
|
||||
func s3TaggingHeader(tagSet []*s3.Tag) string {
|
||||
escape := func(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
pairs := make([]string, 0, len(tagSet))
|
||||
for _, tag := range tagSet {
|
||||
pairs = append(pairs, escape(aws.StringValue(tag.Key))+"="+escape(aws.StringValue(tag.Value)))
|
||||
}
|
||||
return strings.Join(pairs, "&")
|
||||
}
|
||||
|
||||
// multipartCopy copies an object above the CopyObject limit part by part. A
|
||||
// multipart upload does not inherit the source's metadata or tags, so they are
|
||||
// carried over like CopyObject would: metadata from the source's HeadObject,
|
||||
// tags when the remote supports tagging.
|
||||
func (s *s3RemoteStorageClient) multipartCopy(headOut *s3.HeadObjectOutput, src *remote_pb.RemoteStorageLocation, copySource *string, size int64, bucket, key string, storageClass *string) error {
|
||||
createInput := &s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Metadata: headOut.Metadata,
|
||||
ContentType: headOut.ContentType,
|
||||
ContentEncoding: headOut.ContentEncoding,
|
||||
CacheControl: headOut.CacheControl,
|
||||
ContentDisposition: headOut.ContentDisposition,
|
||||
ContentLanguage: headOut.ContentLanguage,
|
||||
WebsiteRedirectLocation: headOut.WebsiteRedirectLocation,
|
||||
ServerSideEncryption: headOut.ServerSideEncryption,
|
||||
SSEKMSKeyId: headOut.SSEKMSKeyId,
|
||||
StorageClass: storageClass,
|
||||
}
|
||||
if headOut.Expires != nil {
|
||||
if expires, parseErr := http.ParseTime(*headOut.Expires); parseErr == nil {
|
||||
createInput.Expires = aws.Time(expires)
|
||||
}
|
||||
}
|
||||
if s.conf.S3SupportTagging {
|
||||
tagOut, tagErr := s.conn.GetObjectTagging(&s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(src.Bucket),
|
||||
Key: aws.String(src.Path[1:]),
|
||||
})
|
||||
if tagErr != nil {
|
||||
return fmt.Errorf("read tags: %w", tagErr)
|
||||
}
|
||||
if len(tagOut.TagSet) > 0 {
|
||||
createInput.Tagging = aws.String(s3TaggingHeader(tagOut.TagSet))
|
||||
}
|
||||
}
|
||||
createOut, err := s.conn.CreateMultipartUpload(createInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
partSize := int64(s3CopyPartSize)
|
||||
for partSize*10000 < size {
|
||||
partSize *= 2
|
||||
}
|
||||
var parts []*s3.CompletedPart
|
||||
for offset, partNumber := int64(0), int64(1); offset < size; offset, partNumber = offset+partSize, partNumber+1 {
|
||||
partOut, partErr := s.conn.UploadPartCopy(&s3.UploadPartCopyInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: createOut.UploadId,
|
||||
PartNumber: aws.Int64(partNumber),
|
||||
CopySource: copySource,
|
||||
CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", offset, min(offset+partSize, size)-1)),
|
||||
// every part from the object the HeadObject saw, not a mix of
|
||||
// versions if it is overwritten mid-copy
|
||||
CopySourceIfMatch: headOut.ETag,
|
||||
})
|
||||
if partErr == nil && partOut.CopyPartResult == nil {
|
||||
partErr = errors.New("no part result")
|
||||
}
|
||||
if partErr != nil {
|
||||
s.abortMultipartUpload(bucket, key, createOut.UploadId)
|
||||
return fmt.Errorf("copy part %d: %w", partNumber, partErr)
|
||||
}
|
||||
parts = append(parts, &s3.CompletedPart{
|
||||
ETag: partOut.CopyPartResult.ETag,
|
||||
PartNumber: aws.Int64(partNumber),
|
||||
})
|
||||
}
|
||||
|
||||
if _, err = s.conn.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: createOut.UploadId,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{Parts: parts},
|
||||
}); err != nil {
|
||||
s.abortMultipartUpload(bucket, key, createOut.UploadId)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) abortMultipartUpload(bucket, key string, uploadId *string) {
|
||||
if _, err := s.conn.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
UploadId: uploadId,
|
||||
}); err != nil {
|
||||
glog.Warningf("abort multipart copy to s3 %s/%s: %v", bucket, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) (err error) {
|
||||
_, err = s.conn.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
awss3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3iface"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// copyObjectMock serves HeadObject for a source of size bytes and records the
|
||||
// copy calls.
|
||||
type copyObjectMock struct {
|
||||
s3iface.S3API
|
||||
size int64
|
||||
missing bool
|
||||
partErr error
|
||||
copyInputs []*awss3.CopyObjectInput
|
||||
partInputs []*awss3.UploadPartCopyInput
|
||||
createInput *awss3.CreateMultipartUploadInput
|
||||
tagInputs []*awss3.GetObjectTaggingInput
|
||||
completed *awss3.CompleteMultipartUploadInput
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) HeadObject(input *awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
|
||||
if m.missing {
|
||||
return nil, awserr.NewRequestFailure(awserr.New("NotFound", "not found", nil), http.StatusNotFound, "")
|
||||
}
|
||||
return &awss3.HeadObjectOutput{
|
||||
ContentLength: aws.Int64(m.size),
|
||||
ContentType: aws.String("image/jpeg"),
|
||||
Expires: aws.String("Wed, 21 Oct 2026 07:28:00 GMT"),
|
||||
WebsiteRedirectLocation: aws.String("/elsewhere"),
|
||||
ServerSideEncryption: aws.String("aws:kms"),
|
||||
SSEKMSKeyId: aws.String("key-1"),
|
||||
ETag: aws.String(`"etag-` + aws.StringValue(input.Key) + `"`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) GetObjectTagging(input *awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
|
||||
m.tagInputs = append(m.tagInputs, input)
|
||||
return &awss3.GetObjectTaggingOutput{TagSet: []*awss3.Tag{
|
||||
{Key: aws.String("album"), Value: aws.String("2026 & co")},
|
||||
{Key: aws.String("a+b"), Value: aws.String("x=y")},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) CopyObject(input *awss3.CopyObjectInput) (*awss3.CopyObjectOutput, error) {
|
||||
m.copyInputs = append(m.copyInputs, input)
|
||||
return &awss3.CopyObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) CreateMultipartUpload(input *awss3.CreateMultipartUploadInput) (*awss3.CreateMultipartUploadOutput, error) {
|
||||
m.createInput = input
|
||||
return &awss3.CreateMultipartUploadOutput{UploadId: aws.String("upload-1")}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) UploadPartCopy(input *awss3.UploadPartCopyInput) (*awss3.UploadPartCopyOutput, error) {
|
||||
m.partInputs = append(m.partInputs, input)
|
||||
if m.partErr != nil && len(m.partInputs) == 2 {
|
||||
return nil, m.partErr
|
||||
}
|
||||
return &awss3.UploadPartCopyOutput{CopyPartResult: &awss3.CopyPartResult{ETag: aws.String(`"part"`)}}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) CompleteMultipartUpload(input *awss3.CompleteMultipartUploadInput) (*awss3.CompleteMultipartUploadOutput, error) {
|
||||
m.completed = input
|
||||
return &awss3.CompleteMultipartUploadOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *copyObjectMock) AbortMultipartUpload(*awss3.AbortMultipartUploadInput) (*awss3.AbortMultipartUploadOutput, error) {
|
||||
m.aborted = true
|
||||
return &awss3.AbortMultipartUploadOutput{}, nil
|
||||
}
|
||||
|
||||
func newCopyTestClient(mock *copyObjectMock) *s3RemoteStorageClient {
|
||||
return &s3RemoteStorageClient{conf: &remote_pb.RemoteConf{Name: "test"}, conn: mock}
|
||||
}
|
||||
|
||||
var (
|
||||
copySrc = &remote_pb.RemoteStorageLocation{Name: "test", Bucket: "bucket", Path: "/src/a b.jpg"}
|
||||
copyDst = &remote_pb.RemoteStorageLocation{Name: "test", Bucket: "bucket", Path: "/dst/a b.jpg"}
|
||||
)
|
||||
|
||||
func TestS3ClientImplementsObjectCopier(t *testing.T) {
|
||||
var _ remote_storage.RemoteStorageObjectCopier = &s3RemoteStorageClient{}
|
||||
}
|
||||
|
||||
func TestS3CopyFileUsesCopyObjectUpToTheLimit(t *testing.T) {
|
||||
mock := ©ObjectMock{size: s3CopyObjectSizeLimit}
|
||||
|
||||
remoteEntry, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, mock.copyInputs, 1)
|
||||
require.Equal(t, "bucket/src/a%20b.jpg", aws.StringValue(mock.copyInputs[0].CopySource))
|
||||
require.Equal(t, "dst/a b.jpg", aws.StringValue(mock.copyInputs[0].Key))
|
||||
require.Equal(t, "aws:kms", aws.StringValue(mock.copyInputs[0].ServerSideEncryption))
|
||||
require.Equal(t, "key-1", aws.StringValue(mock.copyInputs[0].SSEKMSKeyId))
|
||||
require.Nil(t, mock.createInput)
|
||||
// the RemoteEntry describes the destination
|
||||
require.Equal(t, `"etag-dst/a b.jpg"`, remoteEntry.RemoteETag)
|
||||
require.Equal(t, int64(s3CopyObjectSizeLimit), remoteEntry.RemoteSize)
|
||||
}
|
||||
|
||||
func TestS3CopyFileCopiesLargeObjectsInParts(t *testing.T) {
|
||||
size := int64(s3CopyObjectSizeLimit) + 1
|
||||
mock := ©ObjectMock{size: size}
|
||||
|
||||
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, mock.copyInputs)
|
||||
require.NotNil(t, mock.createInput)
|
||||
require.Equal(t, "image/jpeg", aws.StringValue(mock.createInput.ContentType))
|
||||
require.Equal(t, "/elsewhere", aws.StringValue(mock.createInput.WebsiteRedirectLocation))
|
||||
require.Equal(t, "aws:kms", aws.StringValue(mock.createInput.ServerSideEncryption))
|
||||
require.Equal(t, "key-1", aws.StringValue(mock.createInput.SSEKMSKeyId))
|
||||
require.Equal(t, int64(1792567680), aws.TimeValue(mock.createInput.Expires).Unix())
|
||||
// tags only where the remote supports tagging
|
||||
require.Empty(t, mock.tagInputs)
|
||||
require.Nil(t, mock.createInput.Tagging)
|
||||
require.Len(t, mock.partInputs, 11)
|
||||
require.Equal(t, "bytes=0-536870911", aws.StringValue(mock.partInputs[0].CopySourceRange))
|
||||
require.Equal(t, "bytes=5368709120-5368709120", aws.StringValue(mock.partInputs[10].CopySourceRange))
|
||||
for _, part := range mock.partInputs {
|
||||
require.Equal(t, `"etag-src/a b.jpg"`, aws.StringValue(part.CopySourceIfMatch))
|
||||
}
|
||||
require.Len(t, mock.completed.MultipartUpload.Parts, 11)
|
||||
require.Equal(t, int64(11), aws.Int64Value(mock.completed.MultipartUpload.Parts[10].PartNumber))
|
||||
require.False(t, mock.aborted)
|
||||
}
|
||||
|
||||
func TestS3CopyFileCarriesTagsIntoAMultipartCopy(t *testing.T) {
|
||||
mock := ©ObjectMock{size: s3CopyObjectSizeLimit + 1}
|
||||
client := newCopyTestClient(mock)
|
||||
client.conf.S3SupportTagging = true
|
||||
|
||||
_, err := client.CopyFile(copySrc, copyDst)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, mock.tagInputs, 1)
|
||||
require.Equal(t, "src/a b.jpg", aws.StringValue(mock.tagInputs[0].Key))
|
||||
require.Equal(t, "album=2026%20%26%20co&a%2Bb=x%3Dy", aws.StringValue(mock.createInput.Tagging))
|
||||
}
|
||||
|
||||
func TestS3CopyFileAbortsAFailedMultipartCopy(t *testing.T) {
|
||||
mock := ©ObjectMock{size: 2 * s3CopyObjectSizeLimit, partErr: errors.New("part failed")}
|
||||
|
||||
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
|
||||
require.ErrorContains(t, err, "part failed")
|
||||
require.True(t, mock.aborted)
|
||||
require.Nil(t, mock.completed)
|
||||
}
|
||||
|
||||
func TestS3CopyFileMissingSource(t *testing.T) {
|
||||
mock := ©ObjectMock{missing: true}
|
||||
|
||||
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
|
||||
require.ErrorIs(t, err, remote_storage.ErrRemoteObjectNotFound)
|
||||
require.Empty(t, mock.copyInputs)
|
||||
}
|
||||
@@ -110,6 +110,7 @@ func (s3a *S3ApiServer) createMultipartUpload(r *http.Request, input *s3.CreateM
|
||||
if input.ContentType != nil {
|
||||
entry.Attributes.Mime = *input.ContentType
|
||||
}
|
||||
applyPutObjectACL(r, entry)
|
||||
|
||||
// Prepare and apply encryption configuration within directory creation
|
||||
// This ensures encryption resources are only allocated if directory creation succeeds
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseS3MetadataIgnoresInternalAclHeaders(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "http://s3/b/o?uploads", nil)
|
||||
req.Header.Set(s3_constants.ExtAmzAclKey, `[{"Grantee":{"Type":"Group","URI":"http://acs.amazonaws.com/groups/global/AllUsers"},"Permission":"READ"}]`)
|
||||
req.Header.Set(s3_constants.ExtAmzOwnerKey, "forged-owner")
|
||||
req.Header.Set("X-Amz-Meta-Color", "red")
|
||||
|
||||
metadata, code := ParseS3Metadata(req, nil, false)
|
||||
require.Equal(t, s3err.ErrNone, code)
|
||||
require.NotContains(t, metadata, s3_constants.ExtAmzAclKey)
|
||||
require.NotContains(t, metadata, s3_constants.ExtAmzOwnerKey)
|
||||
require.Equal(t, "red", string(metadata["X-Amz-Meta-Color"]))
|
||||
}
|
||||
|
||||
// Multipart initiation previously trusted the internal Seaweed-X-Amz-*
|
||||
// headers, so a caller could forge ownership or a public ACL onto the
|
||||
// completed object without s3:PutObjectAcl. Initiate must run the same ACL
|
||||
// validation as PUT and store only the validated grants.
|
||||
func TestNewMultipartUploadInitiateACL(t *testing.T) {
|
||||
const bucket, object, writer, bucketOwner = "mpu-bucket", "dir/blob.bin", "mpu-writer", "bucket-owner"
|
||||
forgedACL := `[{"Grantee":{"Type":"Group","URI":"http://acs.amazonaws.com/groups/global/AllUsers"},"Permission":"READ"}]`
|
||||
|
||||
setup := func(t *testing.T, writeAcp bool) (*S3ApiServer, *ambiguousPutFiler) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
f := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, f))
|
||||
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
|
||||
t.Cleanup(s3a.iam.Shutdown)
|
||||
s3a.iam.isAuthEnabled = true
|
||||
account := &Account{Id: writer, DisplayName: writer}
|
||||
identity := &Identity{Name: "mpu-acl-test", Account: account, IsStatic: true,
|
||||
Actions: []Action{"Write:" + bucket + "/*"},
|
||||
Credentials: []*Credential{{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}}}
|
||||
if writeAcp {
|
||||
identity.Actions = append(identity.Actions, Action("WriteAcp:"+bucket+"/*"))
|
||||
}
|
||||
s3a.iam.accessKeyIdent[routingTestAccessKey] = identity
|
||||
s3a.iam.nameToIdentity[identity.Name] = identity
|
||||
s3a.iam.accounts[writer] = account
|
||||
s3a.iam.accounts[bucketOwner] = &Account{Id: bucketOwner, DisplayName: bucketOwner}
|
||||
bucketEntry := &filer_pb.Entry{Name: bucket, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{},
|
||||
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte(bucketOwner)}}
|
||||
bucketEntry.Extended[s3_constants.ExtOwnershipKey] = []byte(s3_constants.OwnershipObjectWriter)
|
||||
f.entries["/buckets/"+bucket] = bucketEntry
|
||||
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
||||
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: s3_constants.OwnershipObjectWriter})
|
||||
return s3a, f
|
||||
}
|
||||
|
||||
initiate := func(t *testing.T, s3a *S3ApiServer, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "http://s3/"+bucket+"/"+object+"?uploads", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": object})
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
signRoutingTestRequest(t, req, "", "s3")
|
||||
rr := httptest.NewRecorder()
|
||||
s3a.iam.Auth(s3a.NewMultipartUploadHandler, s3_constants.ACTION_WRITE)(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
uploadEntry := func(t *testing.T, f *ambiguousPutFiler) *filer_pb.Entry {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
prefix := "/buckets/" + bucket + "/" + s3_constants.MultipartUploadsFolder + "/"
|
||||
for key, entry := range f.entries {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
t.Fatalf("no upload entry recorded under %s", prefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("forged internal headers are ignored", func(t *testing.T) {
|
||||
s3a, f := setup(t, false)
|
||||
rr := initiate(t, s3a, map[string]string{
|
||||
s3_constants.ExtAmzAclKey: forgedACL,
|
||||
s3_constants.ExtAmzOwnerKey: "forged-owner",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
entry := uploadEntry(t, f)
|
||||
require.Equal(t, writer, string(entry.Extended[s3_constants.ExtAmzOwnerKey]))
|
||||
require.NotEqual(t, forgedACL, string(entry.Extended[s3_constants.ExtAmzAclKey]))
|
||||
for _, grant := range GetAcpGrants(entry.Extended) {
|
||||
require.Nil(t, grant.Grantee.URI)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit acl requires write acp", func(t *testing.T) {
|
||||
s3a, f := setup(t, false)
|
||||
rr := initiate(t, s3a, map[string]string{s3_constants.AmzCannedAcl: "public-read"})
|
||||
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
||||
require.Empty(t, f.entries["/buckets/"+bucket+"/"+s3_constants.MultipartUploadsFolder])
|
||||
})
|
||||
|
||||
t.Run("explicit acl lands on upload entry", func(t *testing.T) {
|
||||
s3a, f := setup(t, true)
|
||||
rr := initiate(t, s3a, map[string]string{s3_constants.AmzCannedAcl: "public-read"})
|
||||
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
grants := GetAcpGrants(uploadEntry(t, f).Extended)
|
||||
var publicRead bool
|
||||
for _, grant := range grants {
|
||||
if grant.Grantee.URI != nil && *grant.Grantee.URI == "http://acs.amazonaws.com/groups/global/AllUsers" && grant.Permission != nil && *grant.Permission == "READ" {
|
||||
publicRead = true
|
||||
}
|
||||
}
|
||||
require.True(t, publicRead, "public-read grants should persist on the upload entry: %s", string(uploadEntry(t, f).Extended[s3_constants.ExtAmzAclKey]))
|
||||
})
|
||||
}
|
||||
@@ -94,18 +94,6 @@ func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool
|
||||
metadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(keyMD5)
|
||||
}
|
||||
|
||||
// ACL owner
|
||||
acpOwner := r.Header.Get(s3_constants.ExtAmzOwnerKey)
|
||||
if len(acpOwner) > 0 {
|
||||
metadata[s3_constants.ExtAmzOwnerKey] = []byte(acpOwner)
|
||||
}
|
||||
|
||||
// ACL grants
|
||||
acpGrants := r.Header.Get(s3_constants.ExtAmzAclKey)
|
||||
if len(acpGrants) > 0 {
|
||||
metadata[s3_constants.ExtAmzAclKey] = []byte(acpGrants)
|
||||
}
|
||||
|
||||
return metadata, s3err.ErrNone
|
||||
}
|
||||
|
||||
|
||||
@@ -2430,8 +2430,10 @@ func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Reques
|
||||
// Apply S3 passthrough headers from query parameters
|
||||
// AWS S3 supports overriding response headers via query parameters like:
|
||||
// ?response-cache-control=no-cache&response-content-type=application/json
|
||||
// This allows presigned URLs to control how browsers handle the downloaded content
|
||||
if r != nil {
|
||||
// This allows presigned URLs to control how browsers handle the downloaded content.
|
||||
// AWS requires the request to be signed; anonymous requests must not influence
|
||||
// how the stored object is served.
|
||||
if r != nil && getRequestAuthType(r) != authTypeAnonymous {
|
||||
for queryParam, headerValue := range r.URL.Query() {
|
||||
if normalizedHeader, ok := s3_constants.PassThroughHeaders[strings.ToLower(queryParam)]; ok && len(headerValue) > 0 && headerValue[0] != "" {
|
||||
w.Header().Set(normalizedHeader, headerValue[0])
|
||||
|
||||
@@ -238,3 +238,30 @@ func TestRemoteObjectETagFallback(t *testing.T) {
|
||||
t.Errorf("local object ETag = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetResponseHeadersIgnoresOverridesForAnonymous(t *testing.T) {
|
||||
s3a := &S3ApiServer{}
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "object",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 8, Mtime: 100},
|
||||
}
|
||||
for name, url := range map[string]string{
|
||||
"anonymous": "/bucket/object?response-content-type=text%2Fhtml&response-content-disposition=inline",
|
||||
"signed": "/bucket/object?response-content-type=text%2Fhtml&response-content-disposition=inline",
|
||||
"presigned": "/bucket/object?X-Amz-Credential=k&response-content-type=text%2Fhtml",
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if name == "signed" {
|
||||
r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=k")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s3a.setResponseHeaders(w, r, entry, 8)
|
||||
if name == "anonymous" {
|
||||
if got := w.Header().Get("Content-Disposition"); got != "" {
|
||||
t.Errorf("anonymous request must not override response headers, got Content-Disposition %q", got)
|
||||
}
|
||||
} else if got := w.Header().Get("Content-Type"); got != "text/html" {
|
||||
t.Errorf("%s request should apply response overrides, got Content-Type %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit ACL on initiate applies to the completed object and requires
|
||||
// s3:PutObjectAcl, same as PUT. The validated grants ride on the upload
|
||||
// entry so CompleteMultipartUpload cannot be handed forged internal headers.
|
||||
r, aclCode := s3a.preparePutObjectACL(r, bucket)
|
||||
if aclCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, aclCode)
|
||||
return
|
||||
}
|
||||
|
||||
createMultipartUploadInput := &s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: objectKey(aws.String(object)),
|
||||
|
||||
@@ -2,7 +2,6 @@ package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
@@ -56,46 +54,7 @@ func (s3a *S3ApiServer) checkAdminAuth(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func checkLocalPeer(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
pr, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
if _, isUnix := pr.Addr.(*net.UnixAddr); isUnix {
|
||||
return nil
|
||||
}
|
||||
var ip net.IP
|
||||
if tcpAddr, ok := pr.Addr.(*net.TCPAddr); ok {
|
||||
ip = tcpAddr.IP
|
||||
} else if h, _, err := net.SplitHostPort(pr.Addr.String()); err == nil {
|
||||
ip = net.ParseIP(h)
|
||||
} else {
|
||||
ip = net.ParseIP(pr.Addr.String())
|
||||
}
|
||||
if ip != nil && (ip.IsLoopback() || isLocalAddress(ip)) {
|
||||
return nil
|
||||
}
|
||||
glog.V(1).Infof("rejected unauthenticated admin gRPC call from %s: no jwt.filer_signing.key configured", pr.Addr)
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
|
||||
// isLocalAddress enumerates interfaces per call: only reachable for
|
||||
// non-loopback TCP peers on the no-key admin path, which is low-volume —
|
||||
// and a cached set would keep trusting an address after it is removed from
|
||||
// the host and reassigned.
|
||||
func isLocalAddress(ip net.IP) bool {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return security.CheckLocalGrpcPeer(ctx)
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) PutIdentity(ctx context.Context, req *iam_pb.PutIdentityRequest) (*iam_pb.PutIdentityResponse, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package s3tables
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
||||
@@ -120,3 +121,50 @@ func TestDeleteTableRefusesLiveSiblingDataPath(t *testing.T) {
|
||||
assert.NotNil(t, fs.Get(GetTablePath(renameTestBucket, "ns", "t"), "data"),
|
||||
"the targeted table's data must survive")
|
||||
}
|
||||
|
||||
// A location inside another table's directory carries no catalog marker of its
|
||||
// own — the marker lives on the name path. Dropping a table whose
|
||||
// MetadataLocation resolves into nsa/vtab/data must be refused by the ancestor
|
||||
// check, or the victim's marker-less data subdirectory is purged wholesale.
|
||||
func TestDeleteTableRefusesNestedSiblingDataPath(t *testing.T) {
|
||||
fs, m := startRenameManager(t)
|
||||
|
||||
nsaMeta, _ := json.Marshal(namespaceMetadata{Namespace: []string{"nsa"}, OwnerAccountID: DefaultAccountID})
|
||||
fs.Put(GetTableBucketPath(renameTestBucket), "nsa", map[string][]byte{ExtendedKeyMetadata: nsaMeta})
|
||||
|
||||
victimMeta, _ := json.Marshal(tableMetadataInternal{
|
||||
Name: "vtab",
|
||||
Namespace: "nsa",
|
||||
Format: "ICEBERG",
|
||||
OwnerAccountID: DefaultAccountID,
|
||||
MetadataLocation: "s3://" + renameTestBucket + "/nsa/vtab/metadata/v1.metadata.json",
|
||||
})
|
||||
victimTablePath := GetTablePath(renameTestBucket, "nsa", "vtab")
|
||||
fs.Put(GetNamespacePath(renameTestBucket, "nsa"), "vtab", map[string][]byte{
|
||||
ExtendedKeyMetadata: victimMeta,
|
||||
ExtendedKeyEntryType: []byte(EntryTypeTable),
|
||||
})
|
||||
fs.Put(victimTablePath, "data", nil)
|
||||
fs.Put(path.Join(victimTablePath, "data"), "important-0001.parquet", nil)
|
||||
|
||||
evilMeta, _ := json.Marshal(tableMetadataInternal{
|
||||
Name: "evil",
|
||||
Namespace: "attackerns2",
|
||||
Format: "ICEBERG",
|
||||
OwnerAccountID: DefaultAccountID,
|
||||
MetadataLocation: "s3://" + renameTestBucket + "/nsa/vtab/data/metadata/v1.metadata.json",
|
||||
})
|
||||
nsMeta, _ := json.Marshal(namespaceMetadata{Namespace: []string{"attackerns2"}, OwnerAccountID: DefaultAccountID})
|
||||
fs.Put(GetTableBucketPath(renameTestBucket), "attackerns2", map[string][]byte{ExtendedKeyMetadata: nsMeta})
|
||||
fs.Put(GetNamespacePath(renameTestBucket, "attackerns2"), "evil", map[string][]byte{
|
||||
ExtendedKeyMetadata: evilMeta,
|
||||
ExtendedKeyEntryType: []byte(EntryTypeTable),
|
||||
})
|
||||
|
||||
require.Error(t, runDeleteTable(t, m, fs, "attackerns2", "evil"))
|
||||
|
||||
assert.NotNil(t, fs.Get(path.Join(victimTablePath, "data"), "important-0001.parquet"),
|
||||
"data inside another table's directory must survive")
|
||||
assert.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "nsa"), "vtab"),
|
||||
"the victim table's catalog entry must survive")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -100,7 +101,7 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
||||
return err
|
||||
}
|
||||
@@ -233,7 +234,7 @@ func (h *S3TablesHandler) handleRegisterTable(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
bucketName, namespaceName, tableName := target.bucketName, target.namespaceName, target.tableName
|
||||
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
||||
return err
|
||||
}
|
||||
@@ -996,6 +997,26 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
|
||||
// still a live catalog entry. Only this table's authorization was
|
||||
// checked; a decoupled location has had its catalog attributes
|
||||
// stripped, so a surviving marker means the path belongs elsewhere.
|
||||
// The marker lives on the entry's name path, so ancestors of the
|
||||
// data path are checked as well: a location inside another table's
|
||||
// directory resolves above any marker the data path itself would
|
||||
// carry.
|
||||
for dir := path.Dir(dataPath); dir != GetTableBucketPath(bucketName) && strings.HasPrefix(dir, GetTableBucketPath(bucketName)+"/"); dir = path.Dir(dir) {
|
||||
ancestor, lookupErr := h.lookupEntry(r.Context(), client, dir)
|
||||
if lookupErr != nil {
|
||||
if errors.Is(lookupErr, filer_pb.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("refusing to delete table %s: cannot read data path %q: %w", tableName, dataPath, lookupErr)
|
||||
}
|
||||
if _, ok := ancestor.Extended[ExtendedKeyEntryType]; ok {
|
||||
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
|
||||
}
|
||||
var ownerMeta tableMetadataInternal
|
||||
if data := ancestor.Extended[ExtendedKeyMetadata]; json.Unmarshal(data, &ownerMeta) == nil && ownerMeta.Name != "" {
|
||||
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
|
||||
}
|
||||
}
|
||||
switch _, markerErr := h.getExtendedAttribute(r.Context(), client, dataPath, ExtendedKeyMetadata); {
|
||||
case markerErr == nil:
|
||||
return fmt.Errorf("refusing to delete table %s: data path %q is another catalog entry", tableName, dataPath)
|
||||
@@ -1494,7 +1515,7 @@ func (h *S3TablesHandler) handleUpdateTable(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (h *S3TablesHandler) handleCreateView(w http.ResponseWriter, r *http.Reques
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
||||
return err
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func (h *S3TablesHandler) handleUpdateView(w http.ResponseWriter, r *http.Reques
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
|
||||
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -127,11 +127,13 @@ func TableDataDirFromMetadataLocation(metadataLocation string) string {
|
||||
}
|
||||
|
||||
// ValidateMetadataLocation checks that an s3:// metadata location stays within
|
||||
// the authorized table bucket and rejects traversal segments that path.Join
|
||||
// would collapse to escape the bucket directory. Empty locations are allowed
|
||||
// (the catalog derives one). A non-empty location must include a table path so
|
||||
// its metadata directory is table-specific, not shared at the bucket level.
|
||||
func ValidateMetadataLocation(metadataLocation, bucketName string) error {
|
||||
// the authorized table bucket and inside the caller's own namespace subtree,
|
||||
// the boundary per-tenant policies are delegated on. Without the namespace
|
||||
// check a caller could register a location inside another tenant's data
|
||||
// directory and DeleteTable would purge it. Empty locations are allowed (the
|
||||
// catalog derives one). A non-empty location must include a table path so its
|
||||
// metadata directory is table-specific, not shared at the bucket level.
|
||||
func ValidateMetadataLocation(metadataLocation, bucketName, namespace string) error {
|
||||
if metadataLocation == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -155,6 +157,9 @@ func ValidateMetadataLocation(metadataLocation, bucketName string) error {
|
||||
if !hasSegment {
|
||||
return fmt.Errorf("metadata location must include a table path")
|
||||
}
|
||||
if !strings.HasPrefix(tablePath, namespace+"/") {
|
||||
return fmt.Errorf("metadata location must be within namespace %s", namespace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,29 +4,36 @@ import "testing"
|
||||
|
||||
func TestValidateMetadataLocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loc string
|
||||
bucket string
|
||||
wantErr bool
|
||||
name string
|
||||
loc string
|
||||
bucket string
|
||||
namespace string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty allowed", "", "bkt", false},
|
||||
{"same bucket", "s3://bkt/ns/t/metadata/v1.metadata.json", "bkt", false},
|
||||
{"cross bucket rejected", "s3://other/ns/t/metadata/v1.metadata.json", "bkt", true},
|
||||
{"bucket only rejected", "s3://bkt", "bkt", true},
|
||||
{"bucket with trailing slash rejected", "s3://bkt/", "bkt", true},
|
||||
{"slash-only path rejected", "s3://bkt///", "bkt", true},
|
||||
{"dotdot in path rejected", "s3://bkt/../victim/metadata/v1.metadata.json", "bkt", true},
|
||||
{"dotdot mid path rejected", "s3://bkt/ns/../../victim/metadata/v1.metadata.json", "bkt", true},
|
||||
{"dot segment rejected", "s3://bkt/ns/./t/metadata/v1.metadata.json", "bkt", true},
|
||||
{"backslash segment rejected", "s3://bkt/ns/\\t/metadata/v1.metadata.json", "bkt", true},
|
||||
{"non-s3 scheme rejected", "file:///bkt/ns/t", "bkt", true},
|
||||
{"empty location string", "s3://", "bkt", true},
|
||||
{"empty allowed", "", "bkt", "ns", false},
|
||||
{"own namespace", "s3://bkt/ns/t/metadata/v1.metadata.json", "bkt", "ns", false},
|
||||
{"own namespace arbitrary layout", "s3://bkt/ns/warehouse/t/metadata/v1.metadata.json", "bkt", "ns", false},
|
||||
{"cross bucket rejected", "s3://other/ns/t/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"cross namespace rejected", "s3://bkt/nsa/t/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"cross namespace data dir rejected", "s3://bkt/nsa/vtab/data/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"namespace prefix rejected", "s3://bkt/nsa/vtab/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"outside namespace tree rejected", "s3://bkt/shared/t/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"namespace dir without table path rejected", "s3://bkt/ns", "bkt", "ns", true},
|
||||
{"bucket only rejected", "s3://bkt", "bkt", "ns", true},
|
||||
{"bucket with trailing slash rejected", "s3://bkt/", "bkt", "ns", true},
|
||||
{"slash-only path rejected", "s3://bkt///", "bkt", "ns", true},
|
||||
{"dotdot in path rejected", "s3://bkt/../victim/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"dotdot mid path rejected", "s3://bkt/ns/../../victim/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"dot segment rejected", "s3://bkt/ns/./t/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"backslash segment rejected", "s3://bkt/ns/\\t/metadata/v1.metadata.json", "bkt", "ns", true},
|
||||
{"non-s3 scheme rejected", "file:///bkt/ns/t", "bkt", "ns", true},
|
||||
{"empty location string", "s3://", "bkt", "ns", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateMetadataLocation(tt.loc, tt.bucket)
|
||||
err := ValidateMetadataLocation(tt.loc, tt.bucket, tt.namespace)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ValidateMetadataLocation(%q, %q) err = %v, wantErr = %v", tt.loc, tt.bucket, err, tt.wantErr)
|
||||
t.Fatalf("ValidateMetadataLocation(%q, %q, %q) err = %v, wantErr = %v", tt.loc, tt.bucket, tt.namespace, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// CheckGrpcAdminPeer authenticates the peer of an admin-plane gRPC handler:
|
||||
// a client certificate verified by the server's TLS config, or a Bearer token
|
||||
// signed by signingKey (jwt.filer_signing.key). With no key configured remote
|
||||
// callers cannot be told apart, so only local clients (unix socket, loopback,
|
||||
// or an address assigned to this host) are allowed.
|
||||
func CheckGrpcAdminPeer(ctx context.Context, signingKey SigningKey) error {
|
||||
if isMtlsVerifiedPeer(ctx) {
|
||||
return nil
|
||||
}
|
||||
if len(signingKey) == 0 {
|
||||
return CheckLocalGrpcPeer(ctx)
|
||||
}
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
authHeaders := md.Get("authorization")
|
||||
if len(authHeaders) == 0 {
|
||||
return status.Error(codes.Unauthenticated, "missing authorization metadata")
|
||||
}
|
||||
raw := strings.TrimSpace(authHeaders[0])
|
||||
parts := strings.Fields(raw)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
|
||||
return status.Error(codes.Unauthenticated, "authorization header must use Bearer scheme")
|
||||
}
|
||||
parsed, err := DecodeJwt(signingKey, EncodedJwt(parts[1]), &SeaweedFilerAdminClaims{})
|
||||
if err != nil || parsed == nil || !parsed.Valid {
|
||||
return status.Error(codes.Unauthenticated, "invalid admin token")
|
||||
}
|
||||
if claims, ok := parsed.Claims.(*SeaweedFilerAdminClaims); !ok || claims.SessionId != "" {
|
||||
return status.Error(codes.Unauthenticated, "invalid admin token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrpcAdminPeerCredentials returns outgoing metadata carrying a Bearer token
|
||||
// for admin-plane gRPC handlers, or nil when no signing key is configured.
|
||||
func GrpcAdminPeerCredentials(signingKey SigningKey, expiresAfterSec int) metadata.MD {
|
||||
token := GenJwtForFilerAdmin(signingKey, expiresAfterSec)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return metadata.Pairs("authorization", "Bearer "+string(token))
|
||||
}
|
||||
|
||||
func isMtlsVerifiedPeer(ctx context.Context) bool {
|
||||
pr, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
tlsInfo, ok := pr.AuthInfo.(credentials.TLSInfo)
|
||||
return ok && len(tlsInfo.State.VerifiedChains) > 0
|
||||
}
|
||||
|
||||
// CheckLocalGrpcPeer admits only clients on this host: unix sockets, loopback,
|
||||
// or an address assigned to a local interface.
|
||||
func CheckLocalGrpcPeer(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
pr, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
if _, isUnix := pr.Addr.(*net.UnixAddr); isUnix {
|
||||
return nil
|
||||
}
|
||||
var ip net.IP
|
||||
if tcpAddr, ok := pr.Addr.(*net.TCPAddr); ok {
|
||||
ip = tcpAddr.IP
|
||||
} else if h, _, err := net.SplitHostPort(pr.Addr.String()); err == nil {
|
||||
ip = net.ParseIP(h)
|
||||
} else {
|
||||
ip = net.ParseIP(pr.Addr.String())
|
||||
}
|
||||
if ip != nil && (ip.IsLoopback() || isLocalAddress(ip)) {
|
||||
return nil
|
||||
}
|
||||
glog.V(1).Infof("rejected unauthenticated admin gRPC call from %s: no jwt.filer_signing.key configured", pr.Addr)
|
||||
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
|
||||
}
|
||||
|
||||
// isLocalAddress enumerates interfaces per call: only reachable for
|
||||
// non-loopback TCP peers on the no-key admin path, which is low-volume —
|
||||
// and a cached set would keep trusting an address after it is removed from
|
||||
// the host and reassigned.
|
||||
func isLocalAddress(ip net.IP) bool {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func peerCtx(addr net.Addr) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
|
||||
}
|
||||
|
||||
func bearerCtx(addr net.Addr, signingKey SigningKey) context.Context {
|
||||
ctx := peerCtx(addr)
|
||||
token := GenJwtForFilerAdmin(signingKey, 0)
|
||||
return metadata.NewIncomingContext(ctx, metadata.Pairs("authorization", "Bearer "+string(token)))
|
||||
}
|
||||
|
||||
func TestCheckGrpcAdminPeer(t *testing.T) {
|
||||
key := SigningKey("test-signing-key-0123456789abcdef")
|
||||
loopback := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5000}
|
||||
remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.9"), Port: 5000}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
ctx context.Context
|
||||
signingKey SigningKey
|
||||
wantCode codes.Code
|
||||
}{
|
||||
"no peer info, no key": {context.Background(), nil, codes.Unauthenticated},
|
||||
"loopback, no key": {peerCtx(loopback), nil, codes.OK},
|
||||
"remote, no key": {peerCtx(remote), nil, codes.Unauthenticated},
|
||||
"remote + valid token": {bearerCtx(remote, key), key, codes.OK},
|
||||
"remote + no metadata, key set": {peerCtx(remote), key, codes.Unauthenticated},
|
||||
"remote + bad token": {metadata.NewIncomingContext(peerCtx(remote), metadata.Pairs("authorization", "Bearer wrong")), key, codes.Unauthenticated},
|
||||
"unix socket, no key": {peerCtx(&net.UnixAddr{Name: "/tmp/s.sock"}), nil, codes.OK},
|
||||
"mtls verified peer": {peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: remote,
|
||||
AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{}}}},
|
||||
}), nil, codes.OK},
|
||||
} {
|
||||
err := CheckGrpcAdminPeer(tc.ctx, tc.signingKey)
|
||||
got := codes.OK
|
||||
if err != nil {
|
||||
got = status.Code(err)
|
||||
}
|
||||
if got != tc.wantCode {
|
||||
t.Errorf("%s: got %v want %v (err=%v)", name, got, tc.wantCode, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrpcAdminPeerCredentials(t *testing.T) {
|
||||
if md := GrpcAdminPeerCredentials(nil, 0); len(md) != 0 {
|
||||
t.Errorf("expected nil metadata without signing key, got %v", md)
|
||||
}
|
||||
md := GrpcAdminPeerCredentials(SigningKey("key"), 60)
|
||||
if len(md.Get("authorization")) != 1 {
|
||||
t.Fatalf("expected one authorization entry, got %v", md)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,8 @@ func (fs *FilerServer) doCacheRemoteObjectToLocalCluster(ctx context.Context, re
|
||||
|
||||
glog.V(1).Infof("CacheRemoteObjectToLocalCluster: caching %s/%s (remote size: %d)", req.Directory, req.Name, entry.Remote.RemoteSize)
|
||||
|
||||
storageConf, remoteLocation, err := fs.resolveMountedRemote(ctx, req.Directory, req.Name)
|
||||
readDir, readName := fs.filer.RemoteReadPath(entry, req.Directory, req.Name)
|
||||
storageConf, remoteLocation, err := fs.resolveMountedRemote(ctx, readDir, readName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -45,6 +47,9 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
|
||||
|
||||
defer fs.acquireRenamePathLocks("AtomicRenameEntry", oldParent.Child(req.OldName), newParent.Child(req.NewName))()
|
||||
|
||||
// a SQL store keeps the transaction in the context and refuses calls
|
||||
// through it once committed; the work after the commit uses this one
|
||||
afterCommitCtx := ctx
|
||||
ctx, err := fs.filer.BeginTransaction(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -58,7 +63,8 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
|
||||
|
||||
var metadataEvents []metadataEvent
|
||||
var pendingChunkDeletes []*filer_pb.FileChunk
|
||||
moveErr := fs.moveEntry(ctx, nil, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes)
|
||||
var pendingRemoteCopies []*pendingRemoteCopy
|
||||
moveErr := fs.moveEntry(ctx, nil, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes, &pendingRemoteCopies)
|
||||
if moveErr != nil {
|
||||
fs.filer.RollbackTransaction(ctx)
|
||||
return nil, fmt.Errorf("%s/%s move error: %v", req.OldDirectory, req.OldName, moveErr)
|
||||
@@ -75,8 +81,10 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
|
||||
if len(pendingChunkDeletes) > 0 {
|
||||
fs.filer.DeleteChunksNotRecursive(pendingChunkDeletes)
|
||||
}
|
||||
fs.finishRemoteCopies(afterCommitCtx, pendingRemoteCopies)
|
||||
applyRemoteCopies(metadataEvents, pendingRemoteCopies)
|
||||
for _, event := range metadataEvents {
|
||||
event.notify(fs.filer, ctx, req.Signatures)
|
||||
event.notify(fs.filer, afterCommitCtx, req.Signatures)
|
||||
}
|
||||
|
||||
return &filer_pb.AtomicRenameEntryResponse{}, nil
|
||||
@@ -97,6 +105,7 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
afterCommitCtx := ctx
|
||||
ctx, err = fs.filer.BeginTransaction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -125,7 +134,8 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
|
||||
|
||||
var metadataEvents []metadataEvent
|
||||
var pendingChunkDeletes []*filer_pb.FileChunk
|
||||
moveErr := fs.moveEntry(ctx, stream, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes)
|
||||
var pendingRemoteCopies []*pendingRemoteCopy
|
||||
moveErr := fs.moveEntry(ctx, stream, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes, &pendingRemoteCopies)
|
||||
if moveErr != nil {
|
||||
fs.filer.RollbackTransaction(ctx)
|
||||
return fmt.Errorf("%s/%s move error: %v", req.OldDirectory, req.OldName, moveErr)
|
||||
@@ -138,8 +148,10 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
|
||||
if len(pendingChunkDeletes) > 0 {
|
||||
fs.filer.DeleteChunksNotRecursive(pendingChunkDeletes)
|
||||
}
|
||||
fs.finishRemoteCopies(afterCommitCtx, pendingRemoteCopies)
|
||||
applyRemoteCopies(metadataEvents, pendingRemoteCopies)
|
||||
for _, event := range metadataEvents {
|
||||
event.notify(fs.filer, ctx, req.Signatures)
|
||||
event.notify(fs.filer, afterCommitCtx, req.Signatures)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -149,29 +161,119 @@ type metadataEvent struct {
|
||||
oldEntry *filer.Entry
|
||||
newEntry *filer.Entry
|
||||
deleteChunks bool
|
||||
// the old entry's remote object is deleted once the rename has committed:
|
||||
// it was copied to the new key, and a rename that fails must not lose it
|
||||
deleteOldRemote bool
|
||||
}
|
||||
|
||||
// pendingRemoteCopy is a remote-only rename over a remote-only target whose
|
||||
// copy runs after the commit: the store transaction cannot roll a remote
|
||||
// write back, so the new entry keeps the old key's stamp until the copy lands.
|
||||
type pendingRemoteCopy struct {
|
||||
source *filer.Entry
|
||||
oldPath util.FullPath
|
||||
newPath util.FullPath
|
||||
failed bool
|
||||
copied *filer_pb.RemoteEntry
|
||||
}
|
||||
|
||||
// finishRemoteCopies runs the deferred remote copies after the commit. A
|
||||
// failure leaves the new entry pointing at the old key, whose object the
|
||||
// caller then keeps: remote.sync completes the copy the same way it does a
|
||||
// rename whose event it finds unfinished.
|
||||
func (fs *FilerServer) finishRemoteCopies(ctx context.Context, pendings []*pendingRemoteCopy) {
|
||||
for _, pending := range pendings {
|
||||
remoteEntry, err := fs.filer.CopyRemoteOnlyEntry(ctx, pending.source, pending.oldPath, pending.newPath)
|
||||
if err != nil {
|
||||
glog.WarningfCtx(ctx, "remote rename copy of %s: %v", pending.newPath, err)
|
||||
pending.failed = true
|
||||
continue
|
||||
}
|
||||
entry, findErr := fs.filer.FindEntry(ctx, pending.newPath)
|
||||
if findErr != nil {
|
||||
glog.WarningfCtx(ctx, "stamp remote rename copy of %s: %v", pending.newPath, findErr)
|
||||
pending.failed = true
|
||||
continue
|
||||
}
|
||||
entry.Remote = remoteEntry
|
||||
delete(entry.Extended, filer.ExtRemoteRenameSourceKey)
|
||||
if updateErr := fs.filer.UpdateEntry(ctx, entry, entry, false); updateErr != nil {
|
||||
glog.WarningfCtx(ctx, "stamp remote rename copy of %s: %v", pending.newPath, updateErr)
|
||||
pending.failed = true
|
||||
continue
|
||||
}
|
||||
pending.copied = remoteEntry
|
||||
}
|
||||
}
|
||||
|
||||
// applyRemoteCopies brings the queued rename events in line with the deferred
|
||||
// copies. The stamp is written with a plain UpdateEntry, which publishes no
|
||||
// event, so the rename event carries it, without the pending-source marker,
|
||||
// to peer filers and subscribers. A failed copy keeps the old object, which
|
||||
// the entry still reads through the marker.
|
||||
func applyRemoteCopies(events []metadataEvent, pendings []*pendingRemoteCopy) {
|
||||
for i := range events {
|
||||
event := &events[i]
|
||||
for _, pending := range pendings {
|
||||
if event.oldEntry.FullPath != pending.oldPath {
|
||||
continue
|
||||
}
|
||||
if pending.failed {
|
||||
event.deleteOldRemote = false
|
||||
} else if pending.copied != nil && event.newEntry != nil {
|
||||
newEntry := event.newEntry.ShallowClone()
|
||||
newEntry.Remote = pending.copied
|
||||
newEntry.Extended = maps.Clone(newEntry.Extended)
|
||||
delete(newEntry.Extended, filer.ExtRemoteRenameSourceKey)
|
||||
event.newEntry = newEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (event metadataEvent) notify(f *filer.Filer, ctx context.Context, signatures []int32) {
|
||||
if event.deleteOldRemote {
|
||||
deleteRenamedRemoteObject(f, ctx, event.oldEntry)
|
||||
}
|
||||
f.NotifyUpdateEvent(ctx, event.oldEntry, event.newEntry, event.deleteChunks, false, signatures)
|
||||
}
|
||||
|
||||
func (fs *FilerServer) moveEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
|
||||
// deleteRenamedRemoteObject deletes the remote object a committed rename no
|
||||
// longer needs. A child path is not locked by its directory's rename, so
|
||||
// another rename may have put a remote-only entry at the key meanwhile; the
|
||||
// store is asked directly, a lazy fetch would revive the key. When in doubt
|
||||
// the object stays: an extra object loses nothing.
|
||||
func deleteRenamedRemoteObject(f *filer.Filer, ctx context.Context, entry *filer.Entry) {
|
||||
current, findErr := f.Store.FindEntry(ctx, entry.FullPath)
|
||||
switch {
|
||||
case findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound):
|
||||
glog.WarningfCtx(ctx, "keep remote object of renamed %s: look up its old path: %v", entry.FullPath, findErr)
|
||||
case findErr == nil && current.IsInRemoteOnly():
|
||||
glog.V(0).InfofCtx(ctx, "keep remote object of renamed %s: a remote-only entry has taken its path", entry.FullPath)
|
||||
default:
|
||||
if err := f.DeleteFromRemote(ctx, entry); err != nil {
|
||||
glog.WarningfCtx(ctx, "delete remote object of renamed %s: %v", entry.FullPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FilerServer) moveEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
|
||||
|
||||
if err := fs.moveSelfEntry(ctx, stream, oldParent, entry, newParent, newName, func() error {
|
||||
if entry.IsDirectory() {
|
||||
if err := fs.moveFolderSubEntries(ctx, stream, oldParent, entry, newParent, newName, signatures, metadataEvents, pendingChunkDeletes); err != nil {
|
||||
if err := fs.moveFolderSubEntries(ctx, stream, oldParent, entry, newParent, newName, signatures, metadataEvents, pendingChunkDeletes, pendingRemoteCopies); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, signatures, skipTargetLookup, metadataEvents, pendingChunkDeletes); err != nil {
|
||||
}, signatures, skipTargetLookup, metadataEvents, pendingChunkDeletes, pendingRemoteCopies); err != nil {
|
||||
return fmt.Errorf("fail to move %s => %s: %v", oldParent.Child(entry.Name()), newParent.Child(newName), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
|
||||
func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
|
||||
|
||||
currentDirPath := oldParent.Child(entry.Name())
|
||||
newDirPath := newParent.Child(newName)
|
||||
@@ -194,7 +296,7 @@ func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb
|
||||
// println("processing", lastFileName)
|
||||
newChildPath := newDirPath.Child(item.Name())
|
||||
skipTarget := fs.filer.Store.SameActualStore(newDirPath, newChildPath)
|
||||
err := fs.moveEntry(ctx, stream, currentDirPath, item, newDirPath, item.Name(), signatures, skipTarget, metadataEvents, pendingChunkDeletes)
|
||||
err := fs.moveEntry(ctx, stream, currentDirPath, item, newDirPath, item.Name(), signatures, skipTarget, metadataEvents, pendingChunkDeletes, pendingRemoteCopies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +308,7 @@ func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, moveFolderSubEntries func() error, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
|
||||
func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, moveFolderSubEntries func() error, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
|
||||
|
||||
oldPath, newPath := oldParent.Child(entry.Name()), newParent.Child(newName)
|
||||
|
||||
@@ -235,8 +337,48 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
|
||||
case !existingTarget.IsDirectory() && entry.IsDirectory():
|
||||
return fmt.Errorf("%s: %w", existingTarget.FullPath, filer_pb.ErrExistingIsFile)
|
||||
}
|
||||
}
|
||||
|
||||
// a remote-only entry is copied on the remote before anything changes:
|
||||
// deleting the old entry deletes the old object. Over a remote-only
|
||||
// target the copy runs after the commit instead — it overwrites the
|
||||
// target's only content, which a rolled-back commit cannot restore —
|
||||
// and the new entry reads the old key until the copy stamps it.
|
||||
deferredCopy := false
|
||||
var copiedRemote *filer_pb.RemoteEntry
|
||||
if existingTarget != nil && existingTarget.IsInRemoteOnly() && entry.IsInRemoteOnly() {
|
||||
if _, _, copier, copyErr := fs.filer.RemoteCopyLocations(ctx, entry, oldPath, newPath); copyErr != nil {
|
||||
return copyErr
|
||||
} else if copier != nil {
|
||||
deferredCopy = true
|
||||
*pendingRemoteCopies = append(*pendingRemoteCopies, &pendingRemoteCopy{source: sourceEntry, oldPath: oldPath, newPath: newPath})
|
||||
}
|
||||
}
|
||||
if !deferredCopy {
|
||||
var copyErr error
|
||||
copiedRemote, copyErr = fs.filer.CopyRemoteOnlyEntry(ctx, entry, oldPath, newPath)
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
remoteEntry := entry.Remote
|
||||
if copiedRemote != nil {
|
||||
remoteEntry = copiedRemote
|
||||
}
|
||||
|
||||
if existingTarget != nil {
|
||||
targetCtx := filer.WithSuppressedMetadataEvents(ctx)
|
||||
if copiedRemote != nil || deferredCopy {
|
||||
// the copy has replaced or still needs the target's object; keep it
|
||||
targetCtx = filer.WithKeepRemoteObject(targetCtx)
|
||||
existingTarget.Extended = maps.Clone(existingTarget.Extended)
|
||||
if existingTarget.Extended == nil {
|
||||
existingTarget.Extended = map[string][]byte{}
|
||||
}
|
||||
existingTarget.Extended[filer.ExtKeepRemoteObjectKey] = []byte("true")
|
||||
}
|
||||
if deleteErr := fs.filer.DeleteEntryMetaAndData(
|
||||
filer.WithSuppressedMetadataEvents(ctx),
|
||||
targetCtx,
|
||||
newPath,
|
||||
false,
|
||||
false,
|
||||
@@ -258,9 +400,23 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
|
||||
Content: entry.Content,
|
||||
HardLinkCounter: entry.HardLinkCounter,
|
||||
HardLinkId: entry.HardLinkId,
|
||||
Remote: entry.Remote,
|
||||
Remote: remoteEntry,
|
||||
Quota: entry.Quota,
|
||||
}
|
||||
if deferredCopy {
|
||||
// a pending entry renamed again still reads its original source
|
||||
newEntry.Extended = maps.Clone(newEntry.Extended)
|
||||
if newEntry.Extended == nil {
|
||||
newEntry.Extended = map[string][]byte{}
|
||||
}
|
||||
newEntry.Extended[filer.ExtRemoteRenameSourceKey] = []byte(fs.filer.RemoteSourcePath(entry, oldPath))
|
||||
} else if copiedRemote != nil && len(newEntry.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
// the copy came from the pending source; the new key holds it now.
|
||||
// The source itself is left to remote.sync, which deletes it only
|
||||
// while it is still the object the rename event recorded
|
||||
newEntry.Extended = maps.Clone(newEntry.Extended)
|
||||
delete(newEntry.Extended, filer.ExtRemoteRenameSourceKey)
|
||||
}
|
||||
if skipTargetLookup {
|
||||
if newEntry.FullPath.IsLongerFileName(fs.filer.MaxFilenameLength) {
|
||||
return filer_pb.ErrEntryNameTooLong
|
||||
@@ -311,19 +467,36 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
|
||||
})
|
||||
}
|
||||
*metadataEvents = append(*metadataEvents, metadataEvent{
|
||||
oldEntry: sourceEntry,
|
||||
newEntry: newEntry,
|
||||
oldEntry: sourceEntry,
|
||||
newEntry: newEntry,
|
||||
deleteOldRemote: copiedRemote != nil || deferredCopy,
|
||||
})
|
||||
selfEvent := len(*metadataEvents) - 1
|
||||
keepOldRemote := copiedRemote != nil || deferredCopy
|
||||
|
||||
if moveFolderSubEntries != nil {
|
||||
if moveChildrenErr := moveFolderSubEntries(); moveChildrenErr != nil {
|
||||
return moveChildrenErr
|
||||
}
|
||||
// removing the old directory on the remote removes every object under
|
||||
// its prefix, the copied children's old keys included, which must wait
|
||||
// for the commit. Those are deleted one by one after it instead, and
|
||||
// nothing else under the prefix is touched.
|
||||
for _, event := range (*metadataEvents)[selfEvent+1:] {
|
||||
if event.deleteOldRemote {
|
||||
keepOldRemote = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete old entry
|
||||
ctx = context.WithValue(ctx, "OP", "MV")
|
||||
deleteErr := fs.filer.DeleteEntryMetaAndData(filer.WithSuppressedMetadataEvents(ctx), oldPath, false, false, false, false, signatures, 0)
|
||||
deleteCtx := filer.WithSuppressedMetadataEvents(ctx)
|
||||
if keepOldRemote {
|
||||
deleteCtx = filer.WithKeepRemoteObject(deleteCtx)
|
||||
}
|
||||
deleteErr := fs.filer.DeleteEntryMetaAndData(deleteCtx, oldPath, false, false, false, false, signatures, 0)
|
||||
if deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// renameRemoteClient records the remote calls a rename makes. It cannot copy;
|
||||
// copyingRenameRemoteClient adds CopyFile.
|
||||
type renameRemoteClient struct {
|
||||
remote_storage.RemoteStorageClient
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
copyErr error
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) record(call string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls = append(c.calls, call)
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) recorded() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.calls...)
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) StatFile(*remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
||||
return nil, remote_storage.ErrRemoteObjectNotFound
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) ListDirectory(context.Context, *remote_pb.RemoteStorageLocation, remote_storage.VisitFunc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.record("delete " + loc.Bucket + loc.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *renameRemoteClient) RemoveDirectory(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.record("rmdir " + loc.Bucket + loc.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
type copyingRenameRemoteClient struct {
|
||||
*renameRemoteClient
|
||||
}
|
||||
|
||||
func (c copyingRenameRemoteClient) CopyFile(src, dst *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
||||
if c.copyErr != nil {
|
||||
return nil, c.copyErr
|
||||
}
|
||||
c.record("copy " + src.Bucket + src.Path + " " + dst.Bucket + dst.Path)
|
||||
return &filer_pb.RemoteEntry{StorageName: "r1", RemoteSize: 10, RemoteMtime: 1800000000, RemoteETag: `"copy"`}, nil
|
||||
}
|
||||
|
||||
// newRenameRemoteTestServer mounts /buckets/b, /buckets/c and /data/m on r1,
|
||||
// through client.
|
||||
func newRenameRemoteTestServer(t *testing.T, client remote_storage.RemoteStorageClient) (*FilerServer, *renameTestStore) {
|
||||
t.Helper()
|
||||
store := newRenameTestStore()
|
||||
f := newRenameTestFiler(t, store)
|
||||
f.BuildGuardedRemoteClient = func(context.Context, *remote_pb.RemoteConf, bool) (remote_storage.RemoteStorageClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
mapping, err := proto.Marshal(&remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{
|
||||
"/buckets/b": {Name: "r1", Bucket: "origin", Path: "/"},
|
||||
"/buckets/c": {Name: "r1", Bucket: "other", Path: "/"},
|
||||
"/data/m": {Name: "r1", Bucket: "origin", Path: "/m"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
conf, err := proto.Marshal(&remote_pb.RemoteConf{Name: "r1", Type: "s3"})
|
||||
require.NoError(t, err)
|
||||
for name, content := range map[string][]byte{filer.REMOTE_STORAGE_MOUNT_FILE: mapping, "r1" + filer.REMOTE_STORAGE_CONF_SUFFIX: conf} {
|
||||
entry := newFileEntry(filer.DirectoryEtcRemote+"/"+name, 1)
|
||||
entry.Content = content
|
||||
store.entries[string(entry.FullPath)] = entry
|
||||
}
|
||||
require.NoError(t, f.RemoteStorage.LoadRemoteStorageConfigurationsAndMapping(f))
|
||||
for _, dir := range []string{"/buckets", "/buckets/b", "/buckets/b/src", "/buckets/b/dst", "/buckets/c", "/data", "/data/m"} {
|
||||
store.entries[dir] = newDirectoryEntry(dir, 2)
|
||||
}
|
||||
return &FilerServer{filer: f, option: &FilerOption{}, entryLockTable: util.NewLockTable[util.FullPath]()}, store
|
||||
}
|
||||
|
||||
func remoteOnlyRenameEntry(path string, inode uint64) *filer.Entry {
|
||||
entry := newFileEntry(path, inode)
|
||||
entry.FileSize = 10
|
||||
entry.Remote = &filer_pb.RemoteEntry{StorageName: "r1", RemoteSize: 10, RemoteMtime: 1700000000, RemoteETag: `"orig"`}
|
||||
return entry
|
||||
}
|
||||
|
||||
func renameFile(server *FilerServer, oldPath, newPath util.FullPath) error {
|
||||
oldDir, oldName := oldPath.DirAndName()
|
||||
newDir, newName := newPath.DirAndName()
|
||||
_, err := server.AtomicRenameEntry(context.Background(), &filer_pb.AtomicRenameEntryRequest{
|
||||
OldDirectory: oldDir,
|
||||
OldName: oldName,
|
||||
NewDirectory: newDir,
|
||||
NewName: newName,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryCopiesBeforeDeletingTheOldObject(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
|
||||
|
||||
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
|
||||
_, err := store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
|
||||
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
|
||||
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
|
||||
assert.Equal(t, uint64(101), dst.Attr.Inode)
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryOverTargetKeepsTheCopy(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
|
||||
queue := &captureQueue{}
|
||||
swapNotificationQueue(t, queue)
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
|
||||
|
||||
// the target's delete must not take the copy that replaced its object
|
||||
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
|
||||
events := queue.snapshot()
|
||||
require.Len(t, events, 2)
|
||||
assert.True(t, filer.IsMetadataOnlyDelete(events[0].notification.OldEntry), "the target's delete event must leave the remote object alone")
|
||||
assert.False(t, filer.IsMetadataOnlyDelete(events[1].notification.OldEntry))
|
||||
// the copy lands after the commit; the rename event still carries its stamp
|
||||
assert.Equal(t, `"copy"`, events[1].notification.NewEntry.RemoteEntry.GetRemoteETag())
|
||||
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(101), dst.Attr.Inode)
|
||||
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
|
||||
assert.Empty(t, dst.Extended[filer.ExtRemoteRenameSourceKey])
|
||||
// peers replay the post-copy state, not the source's stamp the entry
|
||||
// committed with
|
||||
newEvent := events[1].notification.NewEntry
|
||||
assert.Equal(t, `"copy"`, newEvent.RemoteEntry.RemoteETag)
|
||||
assert.Empty(t, newEvent.Extended[filer.ExtRemoteRenameSourceKey])
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryCopyFailureKeepsTheOldObject(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{copyErr: errors.New("copy refused")}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
|
||||
|
||||
// the copy runs after the commit over a remote-only target, so its
|
||||
// failure cannot roll anything back: the entry still stamps the old
|
||||
// object, which is kept for remote.sync to finish the copy
|
||||
assert.Empty(t, client.recorded())
|
||||
_, err := store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
|
||||
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
|
||||
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"orig"`, dst.Remote.RemoteETag)
|
||||
// reads keep resolving the source's key until the copy stamps the entry:
|
||||
// its own key still holds the target's old object
|
||||
assert.Equal(t, "/buckets/b/src/a.jpg", string(dst.Extended[filer.ExtRemoteRenameSourceKey]))
|
||||
readDir, readName := server.filer.RemoteReadPath(dst, "/buckets/b/dst", "a.jpg")
|
||||
assert.Equal(t, "/buckets/b/src", readDir)
|
||||
assert.Equal(t, "a.jpg", readName)
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryWithoutCopierIsRefused(t *testing.T) {
|
||||
client := &renameRemoteClient{}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
|
||||
err := renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg")
|
||||
require.ErrorContains(t, err, "cannot copy")
|
||||
|
||||
assert.Empty(t, client.recorded())
|
||||
_, err = store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
|
||||
require.NoError(t, err)
|
||||
_, err = store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
|
||||
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryOutOfItsMountIsRefused(t *testing.T) {
|
||||
for _, paths := range [][2]util.FullPath{
|
||||
{"/buckets/b/src/a.jpg", "/buckets/c/a.jpg"},
|
||||
{"/data/m/a.jpg", "/data/a.jpg"},
|
||||
} {
|
||||
t.Run(string(paths[1]), func(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries[string(paths[0])] = remoteOnlyRenameEntry(string(paths[0]), 101)
|
||||
|
||||
err := renameFile(server, paths[0], paths[1])
|
||||
require.ErrorContains(t, err, "cannot be renamed out of it")
|
||||
|
||||
assert.Empty(t, client.recorded())
|
||||
_, err = store.FindEntry(context.Background(), paths[0])
|
||||
require.NoError(t, err)
|
||||
_, err = store.FindEntry(context.Background(), paths[1])
|
||||
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameEmptyRemoteEntryDoesNotCopy(t *testing.T) {
|
||||
// nothing to lose: no copy, and no refusal even out of the mount or
|
||||
// without a copier; the sync writes an empty object at a new key
|
||||
client := &renameRemoteClient{}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
for _, path := range []string{"/buckets/b/src/empty", "/data/m/empty"} {
|
||||
entry := remoteOnlyRenameEntry(path, 101)
|
||||
entry.FileSize = 0
|
||||
entry.Remote.RemoteSize = 0
|
||||
store.entries[path] = entry
|
||||
}
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/empty", "/buckets/b/dst/empty"))
|
||||
require.NoError(t, renameFile(server, "/data/m/empty", "/data/empty"))
|
||||
|
||||
assert.Equal(t, []string{"delete origin/src/empty", "delete origin/m/empty"}, client.recorded())
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryFailingAfterTheCopyKeepsTheSource(t *testing.T) {
|
||||
for _, src := range []util.FullPath{"/buckets/b/src/a.jpg", "/buckets/b/src"} {
|
||||
t.Run(string(src), func(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
store.commitErr = errors.New("commit failed")
|
||||
|
||||
err := renameFile(server, src, "/buckets/b/moved")
|
||||
require.ErrorContains(t, err, "commit failed")
|
||||
|
||||
// the old object, and the old directory holding it, are deleted
|
||||
// only once the rename has committed
|
||||
dst := "origin/moved"
|
||||
if src == "/buckets/b/src" {
|
||||
dst = "origin/moved/a.jpg"
|
||||
}
|
||||
assert.Equal(t, []string{"copy origin/src/a.jpg " + dst}, client.recorded())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameEntryWithLocalDataDoesNotCopy(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
entry := remoteOnlyRenameEntry("/data/m/a.jpg", 101)
|
||||
entry.Chunks = []*filer_pb.FileChunk{{FileId: "1,0123456789", Size: 10}}
|
||||
store.entries["/data/m/a.jpg"] = entry
|
||||
|
||||
// even out of the mount: the old object goes, the content stays local
|
||||
require.NoError(t, renameFile(server, "/data/m/a.jpg", "/data/a.jpg"))
|
||||
|
||||
assert.Equal(t, []string{"delete origin/m/a.jpg"}, client.recorded())
|
||||
dst, err := store.FindEntry(context.Background(), "/data/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"orig"`, dst.Remote.RemoteETag)
|
||||
assert.Len(t, dst.GetChunks(), 1)
|
||||
}
|
||||
|
||||
func TestRenameDirectoryCopiesRemoteOnlyChildren(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
local := remoteOnlyRenameEntry("/buckets/b/src/b.jpg", 102)
|
||||
local.Content = []byte("0123456789")
|
||||
store.entries["/buckets/b/src/b.jpg"] = local
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src", "/buckets/b/moved"))
|
||||
|
||||
// b.jpg's old object goes with its entry, a.jpg's waits for the commit,
|
||||
// and the old prefix is not swept: it could hold a newer object by then
|
||||
assert.Equal(t, []string{
|
||||
"copy origin/src/a.jpg origin/moved/a.jpg",
|
||||
"delete origin/src/b.jpg",
|
||||
"delete origin/src/a.jpg",
|
||||
}, client.recorded())
|
||||
moved, err := store.FindEntry(context.Background(), "/buckets/b/moved/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"copy"`, moved.Remote.RemoteETag)
|
||||
moved, err = store.FindEntry(context.Background(), "/buckets/b/moved/b.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"orig"`, moved.Remote.RemoteETag)
|
||||
}
|
||||
|
||||
func TestDeferredOldKeyDeleteKeepsAKeyRetakenSince(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
event := metadataEvent{
|
||||
oldEntry: remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101),
|
||||
newEntry: remoteOnlyRenameEntry("/buckets/b/moved/a.jpg", 101),
|
||||
deleteOldRemote: true,
|
||||
}
|
||||
|
||||
// another rename put a remote-only entry at the old path after the commit
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 303)
|
||||
event.notify(server.filer, context.Background(), nil)
|
||||
assert.Empty(t, client.recorded())
|
||||
|
||||
// a lookup that fails says nothing about the path
|
||||
delete(store.entries, "/buckets/b/src/a.jpg")
|
||||
store.findErr = errors.New("store unavailable")
|
||||
event.notify(server.filer, context.Background(), nil)
|
||||
assert.Empty(t, client.recorded())
|
||||
|
||||
store.findErr = nil
|
||||
event.notify(server.filer, context.Background(), nil)
|
||||
assert.Equal(t, []string{"delete origin/src/a.jpg"}, client.recorded())
|
||||
}
|
||||
|
||||
func TestRenameRemoteOnlyEntryFinishesAfterTheCommitOnASQLStore(t *testing.T) {
|
||||
// a SQL store keeps the transaction in the context and refuses calls
|
||||
// through it once committed, so the work after the commit must not use it
|
||||
for _, overTarget := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "new path", true: "over a remote-only target"}[overTarget], func(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.strictTx = true
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
if overTarget {
|
||||
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
|
||||
}
|
||||
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
|
||||
|
||||
// the copy is stamped and the old key deleted by the filer itself
|
||||
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
|
||||
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePendingEntryAgainCopiesFromItsSource(t *testing.T) {
|
||||
client := copyingRenameRemoteClient{&renameRemoteClient{copyErr: errors.New("copy refused")}}
|
||||
server, store := newRenameRemoteTestServer(t, client)
|
||||
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
|
||||
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
|
||||
store.entries["/buckets/b/dst/d.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/d.jpg", 303)
|
||||
|
||||
// the deferred copy fails: dst/a.jpg reads src/a.jpg's object
|
||||
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
|
||||
|
||||
// renamed again over another remote-only file, failing again: the marker
|
||||
// keeps naming the original source, not dst/a.jpg's old object
|
||||
require.NoError(t, renameFile(server, "/buckets/b/dst/a.jpg", "/buckets/b/dst/d.jpg"))
|
||||
d, err := store.FindEntry(context.Background(), "/buckets/b/dst/d.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/buckets/b/src/a.jpg", string(d.Extended[filer.ExtRemoteRenameSourceKey]))
|
||||
|
||||
// renamed to a new path, the copy comes from the source and clears the marker
|
||||
client.copyErr = nil
|
||||
require.NoError(t, renameFile(server, "/buckets/b/dst/d.jpg", "/buckets/b/dst/c.jpg"))
|
||||
assert.Contains(t, client.recorded(), "copy origin/src/a.jpg origin/dst/c.jpg")
|
||||
// the source is left to remote.sync: a concurrent rename may have copied
|
||||
// onto that key before its entry is visible
|
||||
assert.NotContains(t, client.recorded(), "delete origin/src/a.jpg")
|
||||
c, err := store.FindEntry(context.Background(), "/buckets/b/dst/c.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, c.Extended[filer.ExtRemoteRenameSourceKey])
|
||||
assert.Equal(t, `"copy"`, c.Remote.RemoteETag)
|
||||
}
|
||||
|
||||
func TestRemoteSourcePathStaysInTheMount(t *testing.T) {
|
||||
server, _ := newRenameRemoteTestServer(t, &renameRemoteClient{})
|
||||
entry := remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
|
||||
for marker, want := range map[string]util.FullPath{
|
||||
"/buckets/b/src/y.jpg": "/buckets/b/src/y.jpg", // same mount: the pending source
|
||||
"/buckets/c/y.jpg": "/buckets/b/x.jpg", // another mount: ignored
|
||||
"/tmp/y.jpg": "/buckets/b/x.jpg", // no mount: ignored
|
||||
} {
|
||||
entry.Extended = map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte(marker)}
|
||||
assert.Equal(t, want, server.filer.RemoteSourcePath(entry, "/buckets/b/x.jpg"), marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaggingCannotSetThePendingSourceMarker(t *testing.T) {
|
||||
server, store := newRenameRemoteTestServer(t, &renameRemoteClient{})
|
||||
store.entries["/buckets/b/x.jpg"] = remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "/buckets/b/x.jpg?tagging", nil)
|
||||
r.Header.Set("Seaweed-X-Remote-Rename-Source", "/buckets/b/other.jpg")
|
||||
r.Header.Set("Seaweed-Album", "2026")
|
||||
server.PutTaggingHandler(httptest.NewRecorder(), r)
|
||||
|
||||
x, err := store.FindEntry(context.Background(), "/buckets/b/x.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2026", string(x.Extended["Seaweed-Album"]))
|
||||
assert.Empty(t, x.Extended[filer.ExtRemoteRenameSourceKey], "only the filer sets the pending source")
|
||||
}
|
||||
|
||||
func TestDeletingTagsKeepsThePendingSourceMarker(t *testing.T) {
|
||||
server, store := newRenameRemoteTestServer(t, &renameRemoteClient{})
|
||||
entry := remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
|
||||
entry.Extended = map[string][]byte{"Seaweed-Album": []byte("2026"), filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/src/x.jpg")}
|
||||
store.entries["/buckets/b/x.jpg"] = entry
|
||||
|
||||
// no tag names: delete every tag
|
||||
server.DeleteTaggingHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/buckets/b/x.jpg?tagging", nil))
|
||||
|
||||
x, err := store.FindEntry(context.Background(), "/buckets/b/x.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, x.Extended["Seaweed-Album"])
|
||||
assert.Equal(t, "/buckets/b/src/x.jpg", string(x.Extended[filer.ExtRemoteRenameSourceKey]), "the marker is not a tag")
|
||||
}
|
||||
@@ -31,6 +31,21 @@ type renameTestStore struct {
|
||||
deleteErr error
|
||||
listErr error // simulates a transient store/RPC failure from a directory listing
|
||||
findDelay time.Duration // optional: widen check-then-act windows in tests
|
||||
findErr error // simulates a failing entry lookup
|
||||
strictTx bool // like a SQL store: calls through a committed transaction fail
|
||||
}
|
||||
|
||||
type renameTestTxKey struct{}
|
||||
|
||||
type renameTestTx struct{ done bool }
|
||||
|
||||
// txDone reports a call through a transaction that has already committed or
|
||||
// rolled back, which a SQL store refuses.
|
||||
func (s *renameTestStore) txDone(ctx context.Context) error {
|
||||
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok && tx.done {
|
||||
return errors.New("sql: transaction has already been committed or rolled back")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRenameTestStore() *renameTestStore {
|
||||
@@ -44,10 +59,23 @@ func (s *renameTestStore) GetName() string { return
|
||||
func (s *renameTestStore) Initialize(util.Configuration, string) error { return nil }
|
||||
func (s *renameTestStore) Shutdown() {}
|
||||
func (s *renameTestStore) BeginTransaction(ctx context.Context) (context.Context, error) {
|
||||
if s.strictTx {
|
||||
return context.WithValue(ctx, renameTestTxKey{}, &renameTestTx{}), nil
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
func (s *renameTestStore) CommitTransaction(context.Context) error { return s.commitErr }
|
||||
func (s *renameTestStore) RollbackTransaction(context.Context) error { return nil }
|
||||
func (s *renameTestStore) CommitTransaction(ctx context.Context) error {
|
||||
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok {
|
||||
tx.done = true
|
||||
}
|
||||
return s.commitErr
|
||||
}
|
||||
func (s *renameTestStore) RollbackTransaction(ctx context.Context) error {
|
||||
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok {
|
||||
tx.done = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *renameTestStore) KvPut(context.Context, []byte, []byte) error {
|
||||
return nil
|
||||
}
|
||||
@@ -56,27 +84,39 @@ func (s *renameTestStore) KvGet(context.Context, []byte) ([]byte, error) {
|
||||
}
|
||||
func (s *renameTestStore) KvDelete(context.Context, []byte) error { return nil }
|
||||
|
||||
func (s *renameTestStore) InsertEntry(_ context.Context, entry *filer.Entry) error {
|
||||
func (s *renameTestStore) InsertEntry(ctx context.Context, entry *filer.Entry) error {
|
||||
if err := s.txDone(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[string(entry.FullPath)] = entry.ShallowClone()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *renameTestStore) UpdateEntry(_ context.Context, entry *filer.Entry) error {
|
||||
func (s *renameTestStore) UpdateEntry(ctx context.Context, entry *filer.Entry) error {
|
||||
if err := s.txDone(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[string(entry.FullPath)] = entry.ShallowClone()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *renameTestStore) FindEntry(_ context.Context, p util.FullPath) (*filer.Entry, error) {
|
||||
func (s *renameTestStore) FindEntry(ctx context.Context, p util.FullPath) (*filer.Entry, error) {
|
||||
if err := s.txDone(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.findDelay > 0 {
|
||||
time.Sleep(s.findDelay)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.findCalls[string(p)]++
|
||||
if s.findErr != nil {
|
||||
return nil, s.findErr
|
||||
}
|
||||
entry, found := s.entries[string(p)]
|
||||
if !found {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
@@ -84,7 +124,10 @@ func (s *renameTestStore) FindEntry(_ context.Context, p util.FullPath) (*filer.
|
||||
return entry.ShallowClone(), nil
|
||||
}
|
||||
|
||||
func (s *renameTestStore) DeleteEntry(_ context.Context, p util.FullPath) error {
|
||||
func (s *renameTestStore) DeleteEntry(ctx context.Context, p util.FullPath) error {
|
||||
if err := s.txDone(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.deleteErr != nil {
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
@@ -42,9 +42,14 @@ func (fs *FilerServer) TraverseBfsMetadata(req *filer_pb.TraverseBfsMetadataRequ
|
||||
// the filer on large trees (e.g. during a peer's first-time bootstrap).
|
||||
sendEntry := func(entry *filer.Entry) error {
|
||||
parent, _ := entry.FullPath.DirAndName()
|
||||
entryTsNs, err := fs.filer.CommitTsNs(ctx, entry.FullPath, entry.Mtime.UnixNano())
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit timestamp of %s: %w", entry.FullPath, err)
|
||||
}
|
||||
if err := stream.Send(&filer_pb.TraverseBfsMetadataResponse{
|
||||
Directory: parent,
|
||||
Entry: entry.ToProtoEntry(),
|
||||
EntryTsNs: entryTsNs,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send traverse bfs metadata response: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -37,8 +38,9 @@ func TestPtrie(t *testing.T) {
|
||||
|
||||
type captureTraverseStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
visited []string
|
||||
ctx context.Context
|
||||
visited []string
|
||||
entryTsNs map[string]int64
|
||||
}
|
||||
|
||||
func (s *captureTraverseStream) Context() context.Context { return s.ctx }
|
||||
@@ -48,7 +50,12 @@ func (s *captureTraverseStream) Send(resp *filer_pb.TraverseBfsMetadataResponse)
|
||||
if dir != "/" {
|
||||
dir += "/"
|
||||
}
|
||||
s.visited = append(s.visited, dir+resp.Entry.Name)
|
||||
path := dir + resp.Entry.Name
|
||||
s.visited = append(s.visited, path)
|
||||
if s.entryTsNs == nil {
|
||||
s.entryTsNs = make(map[string]int64)
|
||||
}
|
||||
s.entryTsNs[path] = resp.EntryTsNs
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -89,6 +96,16 @@ func TestTraverseBfsMetadata(t *testing.T) {
|
||||
}
|
||||
seen[path] = true
|
||||
}
|
||||
|
||||
// Every response carries a commit timestamp for ordering; with no
|
||||
// recorded commit it falls back to the entry's mtime.
|
||||
for path, tsNs := range stream.entryTsNs {
|
||||
if path == "/" {
|
||||
assert.Equal(t, filer.Root.Attr.Mtime.UnixNano(), tsNs)
|
||||
continue
|
||||
}
|
||||
assert.Equalf(t, store.entries[path].Attr.Mtime.UnixNano(), tsNs, "entry_ts_ns for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
var _ filer_pb.SeaweedFiler_TraverseBfsMetadataServer = (*captureTraverseStream)(nil)
|
||||
|
||||
@@ -268,6 +268,9 @@ func NewFilerServer(defaultMux, readonlyMux *http.ServeMux, option *FilerOption)
|
||||
maxFilenameLength := v.GetUint32("filer.options.max_file_name_length")
|
||||
glog.V(0).Infof("max_file_name_length %d", maxFilenameLength)
|
||||
fs.filer = filer.NewFiler(*option.Masters, fs.grpcDialOption, option.Host, option.FilerGroup, option.Collection, option.DefaultReplication, option.DataCenter, maxFilenameLength, nil)
|
||||
// Peer metadata replay takes the same per-path locks, so a replayed change
|
||||
// cannot split a local mutation's read-modify-write.
|
||||
fs.entryLockTable = fs.filer.EntryLockTable
|
||||
fs.filer.Cipher = option.Cipher
|
||||
fs.filer.DefaultDiskType = option.DiskType
|
||||
fs.filer.BuildGuardedRemoteClient = BuildGuardedRemoteStorageClient
|
||||
|
||||
@@ -206,15 +206,18 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Every part of a multipart Range is prepared on its own, so the origin
|
||||
// preflight below is memoized to one stat per request.
|
||||
statOrigin := sync.OnceValue(func() error {
|
||||
dir, name := entry.FullPath.DirAndName()
|
||||
client, remoteLocation, err := fs.mountedRemoteClient(ctx, dir, name)
|
||||
if err != nil {
|
||||
statOriginAt := func(readDir, readName string) func() error {
|
||||
return sync.OnceValue(func() error {
|
||||
client, remoteLocation, err := fs.mountedRemoteClient(ctx, readDir, readName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.StatFile(remoteLocation)
|
||||
return err
|
||||
}
|
||||
_, err = client.StatFile(remoteLocation)
|
||||
return err
|
||||
})
|
||||
})
|
||||
}
|
||||
entryDir, entryName := entry.FullPath.DirAndName()
|
||||
statOrigin := statOriginAt(fs.filer.RemoteReadPath(entry, entryDir, entryName))
|
||||
|
||||
ProcessRangeRequest(r, w, totalSize, mimeType, func(offset int64, size int64) (filer.DoStreamContent, error) {
|
||||
if offset+size <= int64(len(entry.Content)) {
|
||||
@@ -230,13 +233,23 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
|
||||
chunks := entry.GetChunks()
|
||||
if entry.IsInRemoteOnly() {
|
||||
dir, name := entry.FullPath.DirAndName()
|
||||
readDir, readName := fs.filer.RemoteReadPath(entry, dir, name)
|
||||
var mountedLocation *remote_pb.RemoteStorageLocation
|
||||
if fs.filer.RemoteStorage != nil {
|
||||
_, mountedLocation = fs.filer.RemoteStorage.FindMountDirectory(entry.FullPath)
|
||||
}
|
||||
cacheWait := remote_storage.CacheWaitTimeout(entry.Remote.RemoteSize, mountedLocation)
|
||||
if cacheWait <= 0 {
|
||||
return fs.streamFromRemoteOnly(ctx, r, dir, name, offset, size, statOrigin)
|
||||
streamFn, err := fs.streamFromRemoteOnly(ctx, r, readDir, readName, offset, size, statOrigin)
|
||||
if errors.Is(err, filer_pb.ErrNotFound) && (readDir != dir || readName != name) {
|
||||
// the pending copy finished and its source went away since
|
||||
// the entry was read; serve what the entry names now
|
||||
if fresh, findErr := fs.filer.FindEntry(ctx, entry.FullPath); findErr == nil {
|
||||
freshDir, freshName := fs.filer.RemoteReadPath(fresh, dir, name)
|
||||
return fs.streamFromRemoteOnly(ctx, r, freshDir, freshName, offset, size, statOriginAt(freshDir, freshName))
|
||||
}
|
||||
}
|
||||
return streamFn, err
|
||||
}
|
||||
// Bounded wait: a large download outlasts any client timeout, so
|
||||
// serve straight from the origin once the wait expires while the
|
||||
@@ -264,7 +277,7 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
|
||||
// when a later prepare fails; keep those on the 503 retry path.
|
||||
if !strings.Contains(r.Header.Get("Range"), ",") {
|
||||
glog.V(1).InfofCtx(ctx, "stream %s from remote while caching: %v", entry.FullPath, err)
|
||||
if streamFn, remoteErr := fs.streamFromRemote(ctx, dir, name, offset, size); remoteErr == nil {
|
||||
if streamFn, remoteErr := fs.streamFromRemote(ctx, readDir, readName, offset, size); remoteErr == nil {
|
||||
return streamFn, nil
|
||||
} else {
|
||||
glog.WarningfCtx(ctx, "stream %s from remote: %v", entry.FullPath, remoteErr)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -35,7 +36,7 @@ func (fs *FilerServer) PutTaggingHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
for header, values := range r.Header {
|
||||
if strings.HasPrefix(header, needle.PairNamePrefix) {
|
||||
if strings.HasPrefix(header, needle.PairNamePrefix) && header != filer.ExtRemoteRenameSourceKey {
|
||||
for _, value := range values {
|
||||
existingEntry.Extended[header] = []byte(value)
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func (fs *FilerServer) DeleteTaggingHandler(w http.ResponseWriter, r *http.Reque
|
||||
// delete all tags or specific tags
|
||||
hasDeletion := false
|
||||
for header, _ := range existingEntry.Extended {
|
||||
if strings.HasPrefix(header, needle.PairNamePrefix) {
|
||||
if strings.HasPrefix(header, needle.PairNamePrefix) && header != filer.ExtRemoteRenameSourceKey {
|
||||
if len(deletions) == 0 {
|
||||
delete(existingEntry.Extended, header)
|
||||
hasDeletion = true
|
||||
|
||||
@@ -255,7 +255,15 @@ func (fs *FilerServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.filer.DeleteEntryMetaAndData(context.Background(), util.FullPath(objectPath), isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
|
||||
// The delete and the commit record it emits must serialize with peer
|
||||
// replay of this path, same as the gRPC DeleteEntry handler.
|
||||
fullPath := util.FullPath(objectPath)
|
||||
if fs.entryLockTable != nil {
|
||||
pathLock := fs.entryLockTable.AcquireLock("DeleteHandler", fullPath, util.ExclusiveLock)
|
||||
defer fs.entryLockTable.ReleaseLock(fullPath, pathLock)
|
||||
}
|
||||
|
||||
err = fs.filer.DeleteEntryMetaAndData(context.Background(), fullPath, isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
|
||||
if err != nil && err != filer_pb.ErrNotFound {
|
||||
glog.V(1).Infoln("deleting", objectPath, ":", err.Error())
|
||||
writeJsonError(w, r, http.StatusInternalServerError, err)
|
||||
|
||||
@@ -468,6 +468,9 @@ func (fs *FilerServer) saveMetaData(ctx context.Context, r *http.Request, fileNa
|
||||
}
|
||||
for k, v := range r.Header {
|
||||
if len(v) > 0 && len(v[0]) > 0 {
|
||||
if k == filer.ExtRemoteRenameSourceKey {
|
||||
continue // set by the filer only: it picks the remote key reads serve
|
||||
}
|
||||
if strings.HasPrefix(k, needle.PairNamePrefix) || k == "Cache-Control" || k == "Expires" || k == "Content-Disposition" {
|
||||
entry.Extended[k] = []byte(v[0])
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ func (ms *MasterServer) Assign(ctx context.Context, req *master_pb.AssignRequest
|
||||
if !ms.Topo.DataCenterExists(option.DataCenter) {
|
||||
return nil, fmt.Errorf("data center %v not found in topology", option.DataCenter)
|
||||
}
|
||||
if !ms.Topo.RackExists(option.DataCenter, option.Rack) {
|
||||
return nil, fmt.Errorf("rack %v not found in topology", option.Rack)
|
||||
}
|
||||
if !ms.Topo.DataNodeExists(option.DataCenter, option.Rack, option.DataNode) {
|
||||
return nil, fmt.Errorf("data node %v not found in topology", option.DataNode)
|
||||
}
|
||||
|
||||
vl := ms.Topo.GetVolumeLayout(option.Collection, option.ReplicaPlacement, option.Ttl, option.DiskType)
|
||||
if req.DiskType == "" {
|
||||
|
||||
@@ -45,6 +45,23 @@ func (ms *MasterServer) DoAutomaticVolumeGrow(req *topology.VolumeGrowRequest) {
|
||||
ms.broadcastVolumeLocationsToClients(newVidLocations)
|
||||
}
|
||||
|
||||
// periodicGrowCount decides how many volumes the periodic growth scan adds to
|
||||
// a layout ahead of its rack-aware pass, from the writableVolumeCount clients
|
||||
// last asked for (lastGrowCount) and the layout's writable and crowded counts.
|
||||
// mustGrow reports a refill back up to lastGrowCount writable volumes, which
|
||||
// the caller only grows when there is room for all of it. A layout that never
|
||||
// received a writableVolumeCount (lastGrowCount 0) is left to the rack-aware
|
||||
// pass.
|
||||
func periodicGrowCount(lastGrowCount uint32, writable, crowded int) (count uint32, mustGrow bool) {
|
||||
if n := int(lastGrowCount) - writable; n > 0 {
|
||||
return uint32(n), true
|
||||
}
|
||||
if lastGrowCount > 0 && writable < int(lastGrowCount*2) && float64(crowded+volumeGrowStepCount) > float64(writable)*topology.VolumeGrowStrategy.Threshold {
|
||||
return volumeGrowStepCount, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (ms *MasterServer) ProcessGrowRequest() {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
@@ -67,25 +84,25 @@ func (ms *MasterServer) ProcessGrowRequest() {
|
||||
continue
|
||||
}
|
||||
writable, crowded := vl.GetWritableVolumeCount()
|
||||
mustGrow := int(lastGrowCount) - writable
|
||||
vgr := vlc.ToVolumeGrowRequest()
|
||||
underReplicated := vl.CountUnderReplicatedVolumes()
|
||||
stats.MasterVolumeLayoutWritable.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(writable))
|
||||
stats.MasterVolumeLayoutCrowded.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(crowded))
|
||||
stats.MasterUnderReplicatedVolumes.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(underReplicated))
|
||||
|
||||
growCount, mustGrow := periodicGrowCount(lastGrowCount, writable, crowded)
|
||||
switch {
|
||||
case mustGrow > 0:
|
||||
case mustGrow:
|
||||
if rp, rpErr := super_block.NewReplicaPlacementFromString(vgr.Replication); rpErr != nil {
|
||||
glog.V(0).Infof("failed to parse replica placement %s: %v", vgr.Replication, rpErr)
|
||||
} else {
|
||||
vgr.WritableVolumeCount = uint32(mustGrow)
|
||||
vgr.WritableVolumeCount = growCount
|
||||
if ms.Topo.AvailableSpaceFor(&topology.VolumeGrowOption{DiskType: types.ToDiskType(vgr.DiskType)}) >= int64(vgr.WritableVolumeCount*uint32(rp.GetCopyCount())) {
|
||||
_, err = ms.VolumeGrow(ctx, vgr)
|
||||
}
|
||||
}
|
||||
case lastGrowCount > 0 && writable < int(lastGrowCount*2) && float64(crowded+volumeGrowStepCount) > float64(writable)*topology.VolumeGrowStrategy.Threshold:
|
||||
vgr.WritableVolumeCount = volumeGrowStepCount
|
||||
case growCount > 0:
|
||||
vgr.WritableVolumeCount = growCount
|
||||
_, err = ms.VolumeGrow(ctx, vgr)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -492,6 +509,27 @@ func (ms *MasterServer) VolumeGrow(ctx context.Context, req *master_pb.VolumeGro
|
||||
Force: true,
|
||||
Reason: "grpc volume grow",
|
||||
}
|
||||
|
||||
// Report the layout's fill observed at request time whether or not
|
||||
// growth was triggered, so callers can tell an empty layout from a
|
||||
// skipped one. A missing collection/layout reports zeros.
|
||||
var avgFill float64
|
||||
var writableCount int
|
||||
if vl := ms.Topo.FindVolumeLayout(req.Collection, replicaPlacement, ttl, types.ToDiskType(req.DiskType)); vl != nil {
|
||||
avgFill, writableCount = vl.WritableAvgFill(&volumeGrowOption)
|
||||
}
|
||||
|
||||
// Conditional grow: with a threshold set, grow only when the layout's
|
||||
// writable volumes have reached this average fill. A layout with no
|
||||
// writable volumes (none yet, or all just filled) skips the check — that
|
||||
// is the case growth exists to fix.
|
||||
if req.GrowAvgFillThreshold > 0 && writableCount > 0 && avgFill < float64(req.GrowAvgFillThreshold) {
|
||||
return &master_pb.VolumeGrowResponse{
|
||||
AvgFill: float32(avgFill),
|
||||
WritableCount: uint32(writableCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
replicaCount := int64(req.WritableVolumeCount * uint32(replicaPlacement.GetCopyCount()))
|
||||
|
||||
if ms.Topo.AvailableSpaceFor(&volumeGrowOption) < replicaCount {
|
||||
@@ -500,5 +538,8 @@ func (ms *MasterServer) VolumeGrow(ctx context.Context, req *master_pb.VolumeGro
|
||||
|
||||
ms.DoAutomaticVolumeGrow(&volumeGrowRequest)
|
||||
|
||||
return &master_pb.VolumeGrowResponse{}, nil
|
||||
return &master_pb.VolumeGrowResponse{
|
||||
AvgFill: float32(avgFill),
|
||||
WritableCount: uint32(writableCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -190,6 +190,22 @@ func (ms *MasterServer) dirAssignHandler(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
return
|
||||
}
|
||||
if !ms.Topo.RackExists(option.DataCenter, option.Rack) {
|
||||
writeJsonQuiet(w, r, http.StatusBadRequest, operation.AssignResult{
|
||||
Error: fmt.Sprintf("rack %v not found in topology", option.Rack),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !ms.Topo.DataNodeExists(option.DataCenter, option.Rack, option.DataNode) {
|
||||
writeJsonQuiet(w, r, http.StatusBadRequest, operation.AssignResult{
|
||||
Error: fmt.Sprintf("data node %v not found in topology", option.DataNode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// As in gRPC Assign: the periodic growth scan keeps this layout topped up
|
||||
// to the writableVolumeCount clients ask for. 0 (not sent) is ignored.
|
||||
vl.SetLastGrowCount(uint32(writableVolumeCount))
|
||||
|
||||
for time.Since(startTime) < maxTimeout {
|
||||
fid, count, dnList, shouldGrow, err := ms.Topo.PickForWrite(requestedCount, option, vl, expectedDataSize)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sequence"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/topology"
|
||||
)
|
||||
|
||||
const assignTestVolumeSizeLimit = 1 << 30
|
||||
|
||||
// assignTestCluster is a leader master over one data center with one volume
|
||||
// server per rack, like a production cluster spreading "010" replicas.
|
||||
type assignTestCluster struct {
|
||||
ms *MasterServer
|
||||
nodes []*topology.DataNode
|
||||
rp *super_block.ReplicaPlacement
|
||||
nextVid uint32
|
||||
}
|
||||
|
||||
func newAssignTestCluster(t *testing.T, racks int, replication string) *assignTestCluster {
|
||||
t.Helper()
|
||||
rp, err := super_block.NewReplicaPlacementFromString(replication)
|
||||
require.NoError(t, err)
|
||||
c := &assignTestCluster{ms: newLeaderMaster(), rp: rp}
|
||||
// Room for the per-assign size estimate, which would fill newLeaderMaster's
|
||||
// tiny volumes on the first assign.
|
||||
c.ms.Topo = topology.NewTopology("test", sequence.NewMemorySequencer(), assignTestVolumeSizeLimit, 5, false)
|
||||
c.ms.Topo.RaftServer = leaderRaftServer{}
|
||||
dc := c.ms.Topo.GetOrCreateDataCenter("dc1")
|
||||
for i := 0; i < racks; i++ {
|
||||
c.nodes = append(c.nodes, dc.GetOrCreateRack(fmt.Sprintf("rack%d", i)).
|
||||
GetOrCreateDataNode("127.0.0.1", 8080+i, 18080+i, "127.0.0.1", fmt.Sprintf("dn%d", i), map[string]uint32{"": 1000}))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// grow registers n new empty volumes, each with its copies on consecutive
|
||||
// racks, the way a finished volume grow lands in the topology.
|
||||
func (c *assignTestCluster) grow(collection string, n uint32) {
|
||||
for i := uint32(0); i < n; i++ {
|
||||
c.nextVid++
|
||||
v := storage.VolumeInfo{
|
||||
Id: needle.VolumeId(c.nextVid),
|
||||
Collection: collection,
|
||||
ReplicaPlacement: c.rp,
|
||||
Ttl: needle.EMPTY_TTL,
|
||||
Version: needle.GetCurrentVersion(),
|
||||
}
|
||||
for r := 0; r < c.rp.GetCopyCount(); r++ {
|
||||
dn := c.nodes[(int(c.nextVid)+r)%len(c.nodes)]
|
||||
dn.AddOrUpdateVolume(v)
|
||||
c.ms.Topo.RegisterVolumeLayout(v, dn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *assignTestCluster) layout(collection string) *topology.VolumeLayout {
|
||||
return c.ms.Topo.GetVolumeLayout(collection, c.rp, needle.EMPTY_TTL, "")
|
||||
}
|
||||
|
||||
func (c *assignTestCluster) httpAssign(t *testing.T, query string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c.ms.dirAssignHandler(w, httptest.NewRequest(http.MethodGet, "/dir/assign?"+query, nil))
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// A client's writableVolumeCount is what the periodic growth scan keeps a
|
||||
// layout topped up to. gRPC Assign has recorded it since the scan was added;
|
||||
// HTTP /dir/assign parsed it but dropped it, so HTTP-only writers never got
|
||||
// proactive growth. Both entry points must leave the layout in the same state.
|
||||
func TestDirAssignRecordsWritableVolumeCountLikeGrpcAssign(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
counts []uint32 // writableVolumeCount per assign, 0 = not sent
|
||||
want uint32
|
||||
}{
|
||||
{name: "sent", counts: []uint32{30}, want: 30},
|
||||
{name: "not sent", counts: []uint32{0}, want: 0},
|
||||
{name: "later assign without it keeps the last value", counts: []uint32{30, 0}, want: 30},
|
||||
{name: "latest value wins", counts: []uint32{30, 12}, want: 12},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpCluster := newAssignTestCluster(t, 1, "000")
|
||||
httpCluster.grow("c", 1)
|
||||
for _, n := range tt.counts {
|
||||
query := "collection=c&replication=000"
|
||||
if n > 0 {
|
||||
query += fmt.Sprintf("&writableVolumeCount=%d", n)
|
||||
}
|
||||
httpCluster.httpAssign(t, query)
|
||||
}
|
||||
|
||||
grpcCluster := newAssignTestCluster(t, 1, "000")
|
||||
grpcCluster.grow("c", 1)
|
||||
for _, n := range tt.counts {
|
||||
_, err := grpcCluster.ms.Assign(context.Background(), &master_pb.AssignRequest{
|
||||
Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: n,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, grpcCluster.layout("c").GetLastGrowCount(), "gRPC Assign")
|
||||
assert.Equal(t, tt.want, httpCluster.layout("c").GetLastGrowCount(), "HTTP /dir/assign")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An assign naming a placement the topology cannot serve must fail before it
|
||||
// records a growth target: the periodic scan drops the requested placement, so
|
||||
// the volumes it would keep growing could never serve the requester.
|
||||
func TestDirAssignRejectsUnknownPlacementBeforeGrowCount(t *testing.T) {
|
||||
c := newAssignTestCluster(t, 1, "000")
|
||||
c.grow("c", 1)
|
||||
vl := c.layout("c")
|
||||
|
||||
// A rack and data node that exist, but in a different data center: the
|
||||
// requester named dc1, so these placements can never be served either.
|
||||
c.ms.Topo.GetOrCreateDataCenter("dc2").GetOrCreateRack("rackX").
|
||||
GetOrCreateDataNode("127.0.0.2", 8090, 18090, "127.0.0.2", "dnX", map[string]uint32{"": 1000})
|
||||
|
||||
for _, query := range []string{
|
||||
"collection=c&replication=000&writableVolumeCount=30&rack=missing",
|
||||
"collection=c&replication=000&writableVolumeCount=30&dataNode=missing",
|
||||
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&rack=rackX",
|
||||
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&dataNode=dnX",
|
||||
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&rack=rack0&dataNode=dnX",
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
c.ms.dirAssignHandler(w, httptest.NewRequest(http.MethodGet, "/dir/assign?"+query, nil))
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
}
|
||||
assert.Zero(t, vl.GetLastGrowCount())
|
||||
|
||||
for _, req := range []*master_pb.AssignRequest{
|
||||
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, Rack: "missing"},
|
||||
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, DataCenter: "dc1", Rack: "rackX"},
|
||||
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, DataCenter: "dc1", Rack: "rack0", DataNode: "dnX"},
|
||||
} {
|
||||
_, err := c.ms.Assign(context.Background(), req)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
assert.Zero(t, vl.GetLastGrowCount())
|
||||
}
|
||||
|
||||
// Replays the outage of an HTTP-only writer with writableVolumeCount=30 on a
|
||||
// "010" collection across 5 racks. Writes are spread by remaining capacity, so
|
||||
// the 30 volumes fill in lockstep. Before the fix the periodic scan only ever
|
||||
// saw lastGrowCount 0: it added the rack-aware step of 2 when everything was
|
||||
// crowded and nothing more while those 2 stayed uncrowded. When the old batch
|
||||
// went full, the 2 volumes took every write, filled before the next scan, and
|
||||
// assigns failed until an emergency grow landed.
|
||||
func TestPeriodicGrowthKeepsHTTPAssignLayoutWritable(t *testing.T) {
|
||||
const n = 30
|
||||
c := newAssignTestCluster(t, 5, "010")
|
||||
c.grow("photo", n)
|
||||
vl := c.layout("photo")
|
||||
c.httpAssign(t, fmt.Sprintf("collection=photo&replication=010&writableVolumeCount=%d", n))
|
||||
|
||||
const limit = int64(assignTestVolumeSizeLimit)
|
||||
batch := vl.CloneWritableVolumes()
|
||||
writeTo := func(vids []needle.VolumeId, size int64) {
|
||||
for _, vid := range vids {
|
||||
vl.RecordAssign(vid, size)
|
||||
}
|
||||
}
|
||||
// scan runs the per-layout steps of ProcessGrowRequest, with a grow that
|
||||
// lands at once.
|
||||
scan := func() {
|
||||
writable, crowded := vl.GetWritableVolumeCount()
|
||||
count, _ := periodicGrowCount(vl.GetLastGrowCount(), writable, crowded)
|
||||
c.grow("photo", count)
|
||||
for _, plan := range vl.PlanRackAwareGrowth(c.ms.Topo.ListDCAndRacks(), vl.GetLastGrowCount(), volumeGrowStepCount) {
|
||||
c.grow("photo", plan.WritableVolumeCount)
|
||||
}
|
||||
writable, _ = vl.GetWritableVolumeCount()
|
||||
require.LessOrEqual(t, writable, 2*n, "growth must stay bounded by twice the requested writable count")
|
||||
}
|
||||
|
||||
// The batch crosses the crowded threshold together, and stays crowded
|
||||
// over two scans.
|
||||
writeTo(batch, limit*95/100)
|
||||
_, crowded := vl.GetWritableVolumeCount()
|
||||
require.Equal(t, n, crowded)
|
||||
scan()
|
||||
scan()
|
||||
|
||||
// The batch goes full; whatever the scans added is all that is left.
|
||||
writeTo(batch, limit)
|
||||
scan()
|
||||
|
||||
writable, crowded := vl.GetWritableVolumeCount()
|
||||
assert.Zero(t, crowded)
|
||||
assert.GreaterOrEqual(t, writable, n,
|
||||
"after the batch went full the scan must restore the %d writable volumes the client asked for, not leave %d to take every write", n, writable)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (vs *VolumeServer) BatchDelete(ctx context.Context, req *volume_server_pb.B
|
||||
if !req.SkipCookieCheck {
|
||||
cookie := n.Cookie
|
||||
if !isEcVolume {
|
||||
if _, err := vs.store.ReadVolumeNeedle(volumeId, n, nil, nil); err != nil {
|
||||
if _, err := vs.store.ReadVolumeNeedle(volumeId, n, deleteReadOption(), nil); err != nil {
|
||||
resp.Results = append(resp.Results, &volume_server_pb.DeleteResult{
|
||||
FileId: fid,
|
||||
Status: http.StatusNotFound,
|
||||
|
||||
@@ -308,14 +308,19 @@ func shouldAttemptStreamWrite(hasLocalVolume bool, ext string, r *http.Request)
|
||||
if len(ext) > 0 {
|
||||
ext = strings.ToLower(ext)
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
return true, true
|
||||
}
|
||||
_, _, _, shouldResize := shouldResizeImages(ext, r)
|
||||
_, _, _, _, shouldCrop := shouldCropImages(ext, r)
|
||||
if shouldResize || shouldCrop {
|
||||
// A transformed image's length is not the needle's; the payload is
|
||||
// needed to answer — also true for HEAD, which reports it.
|
||||
return false, false
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
// Attempt only, so a needle up to PagedReadLimit is still read whole:
|
||||
// on a spinning disk one read is cheaper than a header read followed
|
||||
// by a trailer read.
|
||||
return true, false
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A HEAD only attempts a metadata-only read, like a GET: a needle up to
|
||||
// PagedReadLimit is read whole. Demanding metadata only would turn that one
|
||||
// read into a header read followed by a trailer read.
|
||||
func TestShouldAttemptStreamWrite(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
ext string
|
||||
hasVolume bool
|
||||
wantAttempt bool
|
||||
wantMustMeta bool
|
||||
}{
|
||||
{"HEAD", http.MethodHead, "/1,abc.jpg", ".jpg", true, true, false},
|
||||
{"GET", http.MethodGet, "/1,abc.jpg", ".jpg", true, true, false},
|
||||
{"GET of a resized image", http.MethodGet, "/1,abc.jpg?width=100", ".jpg", true, false, false},
|
||||
{"HEAD of a resized image", http.MethodHead, "/1,abc.jpg?width=100", ".jpg", true, false, false},
|
||||
{"HEAD of a cropped image", http.MethodHead, "/1,abc.jpg?crop_x1=0&crop_y1=0&crop_x2=100&crop_y2=100", ".jpg", true, false, false},
|
||||
{"HEAD without a local volume", http.MethodHead, "/1,abc.jpg", ".jpg", false, false, false},
|
||||
} {
|
||||
r := httptest.NewRequest(tc.method, tc.target, nil)
|
||||
attempt, mustMeta := shouldAttemptStreamWrite(tc.hasVolume, tc.ext, r)
|
||||
if attempt != tc.wantAttempt || mustMeta != tc.wantMustMeta {
|
||||
t.Errorf("%s: attempt %v, must be metadata only %v; want %v, %v", tc.name, attempt, mustMeta, tc.wantAttempt, tc.wantMustMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (vs *VolumeServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, ok := vs.store.ReadVolumeNeedle(volumeId, n, nil, nil)
|
||||
_, ok := vs.store.ReadVolumeNeedle(volumeId, n, deleteReadOption(), nil)
|
||||
if ok != nil {
|
||||
m := make(map[string]uint32)
|
||||
m["size"] = 0
|
||||
@@ -161,6 +161,17 @@ func (vs *VolumeServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
// deleteReadOption returns the read option used by deletes: read only the
|
||||
// needle's header and trailer.
|
||||
//
|
||||
// A delete needs to know that the needle exists, what its cookie is, and
|
||||
// whether it is a chunk manifest. None of that requires the payload. If the
|
||||
// needle turns out to be a chunk manifest, the read loads its data as well,
|
||||
// because the delete needs the manifest to delete the chunks.
|
||||
func deleteReadOption() *storage.ReadOption {
|
||||
return &storage.ReadOption{AttemptMetaOnly: true, MustMetaOnly: true}
|
||||
}
|
||||
|
||||
func writeDeleteResult(err error, count int64, w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
m := make(map[string]int64)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type copyTestFiler struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
entries map[string]*filer_pb.Entry
|
||||
volumeUrl string
|
||||
}
|
||||
|
||||
func (f *copyTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
if entry, ok := f.entries[req.Directory+"/"+req.Name]; ok {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
||||
}
|
||||
return nil, status.Error(codes.NotFound, "no entry is found")
|
||||
}
|
||||
|
||||
func (f *copyTestFiler) LookupVolume(_ context.Context, req *filer_pb.LookupVolumeRequest) (*filer_pb.LookupVolumeResponse, error) {
|
||||
resp := &filer_pb.LookupVolumeResponse{LocationsMap: make(map[string]*filer_pb.Locations)}
|
||||
for _, vid := range req.VolumeIds {
|
||||
resp.LocationsMap[vid] = &filer_pb.Locations{Locations: []*filer_pb.Location{{Url: f.volumeUrl}}}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func newCopyTestServer(t *testing.T, volumeUrl string, entries map[string]*filer_pb.Entry) *SftpServer {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
grpcServer := grpc.NewServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, ©TestFiler{entries: entries, volumeUrl: volumeUrl})
|
||||
go func() { _ = grpcServer.Serve(lis) }()
|
||||
t.Cleanup(grpcServer.Stop)
|
||||
|
||||
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
||||
return &SftpServer{
|
||||
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
user: &user.User{Username: "alice", Uid: 1000, HomeDir: "/"},
|
||||
}
|
||||
}
|
||||
|
||||
func chunkedEntry(size int64, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(size)},
|
||||
Chunks: chunks,
|
||||
IsDirectory: false,
|
||||
}
|
||||
}
|
||||
|
||||
func fileChunk(fileId string, offset, size int64) *filer_pb.FileChunk {
|
||||
return &filer_pb.FileChunk{FileId: fileId, Offset: offset, Size: uint64(size), ModifiedTsNs: 1}
|
||||
}
|
||||
|
||||
// A non-truncating write must keep untouched bytes, including a sparse file's
|
||||
// zeros between chunks. Stopping at the first gap would drop the tail.
|
||||
func TestCopyExistingContentFillsSparseGaps(t *testing.T) {
|
||||
chunkA := []byte("AAAAAAAAAA")
|
||||
chunkB := []byte("BBBBBBBBBB")
|
||||
objects := map[string][]byte{"3,01": chunkA, "3,02": chunkB}
|
||||
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, ok := objects[r.URL.Path[1:]]
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}))
|
||||
t.Cleanup(volume.Close)
|
||||
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/sparse.bin": chunkedEntry(100, fileChunk("3,01", 0, 10), fileChunk("3,02", 90, 10)),
|
||||
}
|
||||
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/sparse.bin", &dst); err != nil {
|
||||
t.Fatalf("copyExistingContent: %v", err)
|
||||
}
|
||||
want := append(append(append([]byte{}, chunkA...), make([]byte, 80)...), chunkB...)
|
||||
if !bytes.Equal(dst.Bytes(), want) {
|
||||
t.Fatalf("copied %d bytes, want %d with a zero-filled gap; tail present: %v", dst.Len(), len(want), bytes.Contains(dst.Bytes(), chunkB))
|
||||
}
|
||||
}
|
||||
|
||||
// A remote-only file has no local chunks: copying nothing would upload an
|
||||
// empty replacement on close. Refusing the read keeps the remote object.
|
||||
func TestCopyExistingContentRejectsUnreadableEntry(t *testing.T) {
|
||||
remoteOnly := chunkedEntry(0)
|
||||
remoteOnly.RemoteEntry = &filer_pb.RemoteEntry{RemoteSize: 100}
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/remote.bin": remoteOnly,
|
||||
}
|
||||
fs := newCopyTestServer(t, "", entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/remote.bin", &dst); err == nil {
|
||||
t.Fatal("expected refusal when no local bytes exist")
|
||||
}
|
||||
}
|
||||
|
||||
// A locally shortened file still names its older, larger remote object. Once
|
||||
// the local entry is newer the remote size is stale: copying must not pad the
|
||||
// file back out to it.
|
||||
func TestCopyExistingContentIgnoresStaleRemoteSize(t *testing.T) {
|
||||
chunkA := []byte("AAAAAAAAAA")
|
||||
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/3,01" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(chunkA)
|
||||
}))
|
||||
t.Cleanup(volume.Close)
|
||||
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/short.bin": {
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 10, Mtime: 200},
|
||||
Chunks: []*filer_pb.FileChunk{fileChunk("3,01", 0, 10)},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 100, RemoteMtime: 100},
|
||||
},
|
||||
}
|
||||
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/short.bin", &dst); err != nil {
|
||||
t.Fatalf("copyExistingContent: %v", err)
|
||||
}
|
||||
if !bytes.Equal(dst.Bytes(), chunkA) {
|
||||
t.Fatalf("copied %d bytes, want the %d local bytes only", dst.Len(), len(chunkA))
|
||||
}
|
||||
}
|
||||
|
||||
// A file whose size is all holes still keeps its zeros.
|
||||
func TestCopyExistingContentAllHoles(t *testing.T) {
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/holes.bin": chunkedEntry(10),
|
||||
}
|
||||
fs := newCopyTestServer(t, "", entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/holes.bin", &dst); err != nil {
|
||||
t.Fatalf("all-holes file: %v", err)
|
||||
}
|
||||
if dst.Len() != 10 || !bytes.Equal(dst.Bytes(), make([]byte, 10)) {
|
||||
t.Fatalf("wrote %d bytes, want 10 zeros", dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyExistingContentInlineAndMissing(t *testing.T) {
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/inline.txt": {Attributes: &filer_pb.FuseAttributes{FileSize: 5}, Content: []byte("hello")},
|
||||
}
|
||||
fs := newCopyTestServer(t, "", entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/inline.txt", &dst); err != nil {
|
||||
t.Fatalf("inline content: %v", err)
|
||||
}
|
||||
if got := dst.String(); got != "hello" {
|
||||
t.Fatalf("inline content = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
dst.Reset()
|
||||
if err := fs.copyExistingContent("/home/missing.txt", &dst); err != nil || dst.Len() != 0 {
|
||||
t.Fatalf("missing file: err=%v written=%d", err, dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyExistingContentTrailingGap(t *testing.T) {
|
||||
chunkA := []byte("AAAAAAAAAA")
|
||||
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/3,01" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(chunkA)
|
||||
}))
|
||||
t.Cleanup(volume.Close)
|
||||
|
||||
entries := map[string]*filer_pb.Entry{
|
||||
"/home/trailing.bin": chunkedEntry(50, fileChunk("3,01", 0, 10)),
|
||||
}
|
||||
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
|
||||
|
||||
var dst bytes.Buffer
|
||||
if err := fs.copyExistingContent("/home/trailing.bin", &dst); err != nil {
|
||||
t.Fatalf("copyExistingContent: %v", err)
|
||||
}
|
||||
want := append(append([]byte{}, chunkA...), make([]byte, 40)...)
|
||||
if !bytes.Equal(dst.Bytes(), want) {
|
||||
t.Fatalf("copied %d bytes, want %d with a zero-filled tail", dst.Len(), len(want))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -150,9 +152,8 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, _ := util.FullPath(absPath).DirAndName()
|
||||
if err := fs.checkFilePermission(dir, "write"); err != nil {
|
||||
glog.Errorf("Permission denied for %s", dir)
|
||||
if err := fs.checkFilePermission(absPath, "write"); err != nil {
|
||||
glog.Errorf("Permission denied for %s", absPath)
|
||||
return nil, err
|
||||
}
|
||||
// Create a temporary file to buffer writes
|
||||
@@ -160,6 +161,14 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
// Without O_TRUNC the client writes into the existing file, so start from its current bytes.
|
||||
if !r.Pflags().Trunc {
|
||||
if err := fs.copyExistingContent(absPath, tmpFile); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpFile.Name())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &SeaweedSftpFileWriter{
|
||||
fs: *fs,
|
||||
@@ -173,6 +182,75 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) copyExistingContent(absPath string, dst io.Writer) error {
|
||||
entry, err := fs.getEntry(absPath)
|
||||
if err == os.ErrNotExist {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDirectory {
|
||||
return nil
|
||||
}
|
||||
size := int64(filer.FileSize(entry))
|
||||
remote := entry.GetRemoteEntry()
|
||||
if len(entry.Content) == 0 && len(entry.GetChunks()) == 0 {
|
||||
if remote != nil && remote.RemoteSize > 0 {
|
||||
return fmt.Errorf("existing content of %s is not readable", absPath)
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
return writeZeros(dst, size)
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(entry.Content) > 0 {
|
||||
if _, err := dst.Write(entry.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining := size - int64(len(entry.Content)); remaining > 0 {
|
||||
return writeZeros(dst, remaining)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
streamFn, err := filer.PrepareStreamContent(lookupFileIdFn(filer.LookupFn(fs)), filer.JwtForVolumeServer, entry.GetChunks(), 0, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing content of %s: %w", absPath, err)
|
||||
}
|
||||
if err := streamFn(dst); err != nil {
|
||||
return fmt.Errorf("read existing content of %s: %w", absPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupFileIdFn adapts a filer LookupFn to the chunk streamer's provider
|
||||
// interface. PrepareStreamContent fills unstored gaps with zeros, so a sparse
|
||||
// file's tail survives the copy.
|
||||
type lookupFileIdFn wdclient.LookupFileIdFunctionType
|
||||
|
||||
func (fn lookupFileIdFn) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType {
|
||||
return wdclient.LookupFileIdFunctionType(fn)
|
||||
}
|
||||
|
||||
func writeZeros(w io.Writer, size int64) error {
|
||||
zeros := make([]byte, 8192)
|
||||
for size > 0 {
|
||||
n := int64(len(zeros))
|
||||
if size < n {
|
||||
n = size
|
||||
}
|
||||
written, err := w.Write(zeros[:n])
|
||||
size -= int64(written)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) removeEntry(absPath string) error {
|
||||
return fs.deleteEntry(absPath, false)
|
||||
}
|
||||
@@ -247,21 +325,24 @@ func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
|
||||
var all []os.FileInfo
|
||||
last := ""
|
||||
for {
|
||||
page, err := fs.fetchDirectoryPage(dirPath, last)
|
||||
page, lastName, received, err := fs.fetchDirectoryPage(dirPath, last)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) < defaultListLimit {
|
||||
if received < defaultListLimit {
|
||||
break
|
||||
}
|
||||
last = page[len(page)-1].Name()
|
||||
last = lastName
|
||||
}
|
||||
return listerat(all), nil
|
||||
}
|
||||
|
||||
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, error) {
|
||||
// fetchDirectoryPage returns the entries the user may list, plus the last name and count the filer sent.
|
||||
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, string, int, error) {
|
||||
var list []os.FileInfo
|
||||
lastName := ""
|
||||
received := 0
|
||||
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
|
||||
if err != nil {
|
||||
@@ -272,9 +353,14 @@ func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo,
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil || r.Entry == nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Entry == nil {
|
||||
continue
|
||||
}
|
||||
received++
|
||||
lastName = r.Entry.Name
|
||||
p := path.Join(dirPath, r.Entry.Name)
|
||||
if err := fs.checkFilePermission(p, "list"); err != nil {
|
||||
continue
|
||||
@@ -283,7 +369,7 @@ func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return list, err
|
||||
return list, lastName, received, err
|
||||
}
|
||||
|
||||
// makeDir creates a new directory with proper permissions.
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// TestPutFileEscapesQueryInjection ensures a filename containing "?" cannot be
|
||||
@@ -37,3 +49,132 @@ func TestPutFileEscapesQueryInjection(t *testing.T) {
|
||||
t.Errorf("path not delivered literally: got %q, want %q", gotPath, malicious)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeListFiler struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
names []string
|
||||
failFrom int // ListEntries fails after sending this many entries, when > 0
|
||||
}
|
||||
|
||||
func (f *fakeListFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
sent := 0
|
||||
for _, name := range f.names {
|
||||
if name <= req.StartFromFileName || sent >= int(req.Limit) {
|
||||
continue
|
||||
}
|
||||
if f.failFrom > 0 && sent == f.failFrom {
|
||||
return status.Error(codes.Unavailable, "filer went away")
|
||||
}
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}}); err != nil {
|
||||
return err
|
||||
}
|
||||
sent++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeListFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: req.Name, Attributes: &filer_pb.FuseAttributes{}}}, nil
|
||||
}
|
||||
|
||||
func newListTestServer(t *testing.T, filer *fakeListFiler) *SftpServer {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := grpc.NewServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(srv, filer)
|
||||
go srv.Serve(lis)
|
||||
t.Cleanup(srv.Stop)
|
||||
return &SftpServer{
|
||||
filerAddr: pb.ServerAddress(fmt.Sprintf("127.0.0.1:1.%d", lis.Addr().(*net.TCPAddr).Port)),
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
user: &user.User{
|
||||
Username: "alice",
|
||||
HomeDir: "/",
|
||||
Uid: 1001,
|
||||
Gid: 1001,
|
||||
// every entry under /d is listable except f00000
|
||||
Permissions: map[string][]string{"/d": {"list"}, "/d/f00000": {}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listNames(t *testing.T, fs *SftpServer) ([]string, error) {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
lister sftp.ListerAt
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
lister, err := fs.listAllPages("/d")
|
||||
done <- result{lister, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
infos := make([]os.FileInfo, 10000)
|
||||
n, _ := r.lister.ListAt(infos, 0)
|
||||
var names []string
|
||||
for _, fi := range infos[:n] {
|
||||
names = append(names, fi.Name())
|
||||
}
|
||||
return names, nil
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("listing did not return")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fileNames(n int) []string {
|
||||
names := make([]string, n)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("f%05d", i)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestListAllPagesReturnsStreamError(t *testing.T) {
|
||||
fs := newListTestServer(t, &fakeListFiler{names: fileNames(10), failFrom: 3})
|
||||
_, err := listNames(t, fs)
|
||||
if status.Code(err) != codes.Unavailable {
|
||||
t.Fatalf("expected the stream error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllPagesContinuesPastHiddenEntries(t *testing.T) {
|
||||
fs := newListTestServer(t, &fakeListFiler{names: fileNames(defaultListLimit + 500)})
|
||||
names, err := listNames(t, fs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != defaultListLimit+499 {
|
||||
t.Fatalf("expected %d entries, got %d", defaultListLimit+499, len(names))
|
||||
}
|
||||
if names[0] != "f00001" || names[len(names)-1] != fmt.Sprintf("f%05d", defaultListLimit+499) {
|
||||
t.Fatalf("unexpected first/last entries %q, %q", names[0], names[len(names)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllPagesContinuesPastFullyHiddenPage(t *testing.T) {
|
||||
const tail = 10
|
||||
fs := newListTestServer(t, &fakeListFiler{names: fileNames(defaultListLimit + tail)})
|
||||
// The whole first page is hidden from the user; only the tail is listable.
|
||||
for i := 0; i < defaultListLimit; i++ {
|
||||
fs.user.Permissions[fmt.Sprintf("/d/f%05d", i)] = []string{}
|
||||
}
|
||||
names, err := listNames(t, fs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != tail {
|
||||
t.Fatalf("expected the %d entries after the hidden page, got %d", tail, len(names))
|
||||
}
|
||||
if names[0] != fmt.Sprintf("f%05d", defaultListLimit) {
|
||||
t.Fatalf("unexpected first entry %q", names[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func (fs *SftpServer) CheckFilePermission(path string, perm string) error {
|
||||
// check permissions on the parent directory instead
|
||||
if err == os.ErrNotExist {
|
||||
parentPath := stdpath.Dir(path)
|
||||
if parentPath == path {
|
||||
return os.ErrPermission
|
||||
}
|
||||
// Check if user can write to the parent directory
|
||||
return fs.CheckFilePermission(parentPath, perm)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package sftpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestPathWithin(t *testing.T) {
|
||||
@@ -62,3 +72,100 @@ func TestIsPathInHomeDirectorySiblingPrefix(t *testing.T) {
|
||||
t.Error("home /tenants/alice must not match sibling /tenants/alice-archive")
|
||||
}
|
||||
}
|
||||
|
||||
// newFileWriter must honor a more specific ACL on the target file, not just
|
||||
// the parent directory's: a file the user cannot write stays read-only even
|
||||
// in a writable directory. Missing paths still defer to the parent directory.
|
||||
func TestNewFileWriterChecksTargetFile(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
||||
"/": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
||||
"/home": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
||||
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
||||
"/home/alice/locked.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
||||
"/home/alice/ok.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
||||
"/home/alice/nested": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
||||
"/home/alice/nested/ok.f": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
||||
}}
|
||||
grpcServer := grpc.NewServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
||||
go func() { _ = grpcServer.Serve(lis) }()
|
||||
t.Cleanup(grpcServer.Stop)
|
||||
|
||||
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
||||
fs := &SftpServer{
|
||||
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
user: &user.User{
|
||||
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
||||
Permissions: map[string][]string{
|
||||
"/home/alice": {"readwrite"},
|
||||
"/home/alice/locked.txt": {"read"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
write := func(path string) error {
|
||||
w, err := fs.newFileWriter(&sftp.Request{Filepath: path})
|
||||
if writer, ok := w.(*SeaweedSftpFileWriter); ok {
|
||||
_ = os.Remove(writer.tmpFile.Name())
|
||||
_ = writer.tmpFile.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write("/locked.txt"); err == nil {
|
||||
t.Error("expected write denial on a file with an explicit read-only ACL")
|
||||
}
|
||||
if err := write("/ok.txt"); err != nil {
|
||||
t.Errorf("expected write under the directory ACL, got %v", err)
|
||||
}
|
||||
if err := write("/new.txt"); err != nil {
|
||||
t.Errorf("expected write on a new file in a writable dir, got %v", err)
|
||||
}
|
||||
if err := write("/nested/new.txt"); err != nil {
|
||||
t.Errorf("expected write on a new file under a missing intermediate check, got %v", err)
|
||||
}
|
||||
delete(filer.entries, "/home/alice/nested")
|
||||
delete(filer.entries, "/home/alice/nested/ok.f")
|
||||
if err := write("/gone/deeper.txt"); err != nil {
|
||||
t.Errorf("expected write when ancestors resolve to the writable dir, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFilePermissionMissingAncestorsTerminates(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{}}
|
||||
grpcServer := grpc.NewServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
||||
go func() { _ = grpcServer.Serve(lis) }()
|
||||
t.Cleanup(grpcServer.Stop)
|
||||
|
||||
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
||||
fs := &SftpServer{
|
||||
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
user: &user.User{Username: "alice", Uid: 1000, HomeDir: "/home/alice"},
|
||||
}
|
||||
if err := fs.checkFilePermission("/a/b/c", "write"); err == nil {
|
||||
t.Error("expected denial when no ancestor exists")
|
||||
}
|
||||
}
|
||||
|
||||
type permissionTestFiler struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func (f *permissionTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
if entry, ok := f.entries[req.Directory+"/"+req.Name]; ok {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
||||
}
|
||||
return nil, status.Error(codes.NotFound, "no entry is found")
|
||||
}
|
||||
|
||||
@@ -982,7 +982,7 @@ func (ev *EcVolume) LocateEcShardNeedle(needleId types.NeedleId, version needle.
|
||||
return types.Offset{}, 0, nil, fmt.Errorf("FindNeedleFromEcx: %w", err)
|
||||
}
|
||||
|
||||
intervals = ev.LocateEcShardNeedleInterval(version, offset.ToActualOffset(), types.Size(needle.GetActualSize(size, version)))
|
||||
intervals = ev.LocateEcShardNeedleInterval(version, offset.ToActualOffset(), size)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -256,3 +256,30 @@ func TestEcVolumeDestroySweepsBothDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateEcShardNeedleMatchesInterval(t *testing.T) {
|
||||
ecxFile, err := os.OpenFile("./test_files/389.ecx", os.O_RDONLY, 0)
|
||||
assert.NoError(t, err)
|
||||
defer ecxFile.Close()
|
||||
|
||||
stat, err := ecxFile.Stat()
|
||||
assert.NoError(t, err)
|
||||
fileSize := stat.Size()
|
||||
|
||||
ev := &EcVolume{
|
||||
VolumeId: needle.VolumeId(389),
|
||||
ecxFile: ecxFile,
|
||||
ecxFileSize: fileSize,
|
||||
Shards: []*EcVolumeShard{
|
||||
{ecdFileSize: 1118830592},
|
||||
},
|
||||
ECContext: NewDefaultECContext("", 389),
|
||||
}
|
||||
|
||||
needleId, _ := types.ParseNeedleId("0f0edb92")
|
||||
offset, size, intervals, err := ev.LocateEcShardNeedle(needleId, needle.GetCurrentVersion())
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedIntervals := ev.LocateEcShardNeedleInterval(needle.GetCurrentVersion(), offset.ToActualOffset(), size)
|
||||
assert.Equal(t, expectedIntervals, intervals)
|
||||
}
|
||||
|
||||
@@ -504,10 +504,11 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
|
||||
var moves []*move
|
||||
for _, pm := range toMove {
|
||||
hasRoom := func(r string) bool {
|
||||
return r != pm.src.rack && racks[r].freeSlots > 0 &&
|
||||
return r != pm.src.rack && rackFreeForType(racks[r], diskType) > 0 &&
|
||||
(!pm.avoidDataRack || !antiAffinity[r])
|
||||
}
|
||||
destRack, ok := pickTarget(rackKeys, shardsPerRack, maxPerRack, antiAffinity, hasRoom, withinLimit)
|
||||
rackFree := func(r string) int { return rackFreeForType(racks[r], diskType) }
|
||||
destRack, ok := pickTarget(rackKeys, shardsPerRack, maxPerRack, antiAffinity, hasRoom, withinLimit, rackFree)
|
||||
if !ok && totalCap > 0 && rackShardCount[pm.src.rack] > totalCap {
|
||||
// No rack is under the per-type cap. A shard must not stay on a rack
|
||||
// above the total cap while another rack is under it, so relax the
|
||||
@@ -515,12 +516,12 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
|
||||
// inside pickTarget. Only a source above the total cap may relax:
|
||||
// otherwise the destination just takes over the per-type overflow
|
||||
// and the next Plan moves it back.
|
||||
destRack, ok = pickTarget(rackKeys, shardsPerRack, totalCap, antiAffinity, hasRoom, withinLimit)
|
||||
destRack, ok = pickTarget(rackKeys, shardsPerRack, totalCap, antiAffinity, hasRoom, withinLimit, rackFree)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
destNode := pickNodeInRack(racks[destRack], vk, rp)
|
||||
destNode := pickNodeInRack(racks[destRack], vk, rp, diskType)
|
||||
if destNode == nil {
|
||||
continue
|
||||
}
|
||||
@@ -553,15 +554,55 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
|
||||
return moves
|
||||
}
|
||||
|
||||
func pickNodeInRack(r *rack, vk volKey, rp *super_block.ReplicaPlacement) *Node {
|
||||
return pickBestNodeForVolume(sortedNodeSlice(r.nodes), vk, rp)
|
||||
func pickNodeInRack(r *rack, vk volKey, rp *super_block.ReplicaPlacement, diskType string) *Node {
|
||||
return pickBestNodeForVolumeOnDisk(sortedNodeSlice(r.nodes), vk, rp, diskType)
|
||||
}
|
||||
|
||||
// pickBestNodeForVolume returns the node with the fewest shards of the volume that
|
||||
// has a free slot and is under the SameRackCount cap, or nil.
|
||||
// pickBestNodeForVolumeOnDisk is pickBestNodeForVolume restricted to nodes with
|
||||
// free capacity on a disk of diskType, so the pick cannot land on a node whose
|
||||
// room sits on another type.
|
||||
func pickBestNodeForVolumeOnDisk(nodes []*Node, vk volKey, rp *super_block.ReplicaPlacement, diskType string) *Node {
|
||||
eligible := make([]*Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if freeSlotsForType(node, diskType) > 0 {
|
||||
eligible = append(eligible, node)
|
||||
}
|
||||
}
|
||||
return pickBestNodeForVolume(eligible, vk, rp)
|
||||
}
|
||||
|
||||
// freeSlotsForType is the node's free EC slots usable by diskType. Per-disk
|
||||
// metadata is authoritative when present; without it the node's aggregate
|
||||
// stands in so the volume server's own disk selection can place the shard.
|
||||
func freeSlotsForType(n *Node, diskType string) int {
|
||||
if len(n.disks) == 0 {
|
||||
return n.freeSlots
|
||||
}
|
||||
free := 0
|
||||
for _, d := range n.disks {
|
||||
if d.freeSlots > 0 && (diskType == "" || d.diskType == diskType) {
|
||||
free += d.freeSlots
|
||||
}
|
||||
}
|
||||
return free
|
||||
}
|
||||
|
||||
func rackFreeForType(r *rack, diskType string) int {
|
||||
free := 0
|
||||
for _, n := range r.nodes {
|
||||
free += freeSlotsForType(n, diskType)
|
||||
}
|
||||
return free
|
||||
}
|
||||
|
||||
// pickBestNodeForVolume spreads this volume evenly first. Remainders: higher
|
||||
// freeSlots, then lower liveShards+assignedThisPlace. SameRackCount is a hard
|
||||
// cap (no +1 for debt).
|
||||
func pickBestNodeForVolume(nodes []*Node, vk volKey, rp *super_block.ReplicaPlacement) *Node {
|
||||
var best *Node
|
||||
bestCount := -1
|
||||
bestFree := -1
|
||||
bestScore := -1
|
||||
for _, node := range nodes {
|
||||
if node.freeSlots <= 0 {
|
||||
continue
|
||||
@@ -570,8 +611,20 @@ func pickBestNodeForVolume(nodes []*Node, vk volKey, rp *super_block.ReplicaPlac
|
||||
if rp != nil && rp.SameRackCount > 0 && count >= rp.SameRackCount {
|
||||
continue
|
||||
}
|
||||
if best == nil || count < bestCount {
|
||||
best, bestCount = node, count
|
||||
score := nodeLiveShardCount(node)
|
||||
better := false
|
||||
switch {
|
||||
case best == nil:
|
||||
better = true
|
||||
case count != bestCount:
|
||||
better = count < bestCount
|
||||
case node.freeSlots != bestFree:
|
||||
better = node.freeSlots > bestFree
|
||||
default:
|
||||
better = score < bestScore
|
||||
}
|
||||
if better {
|
||||
best, bestCount, bestFree, bestScore = node, count, node.freeSlots, score
|
||||
}
|
||||
}
|
||||
return best
|
||||
@@ -662,12 +715,21 @@ func withinRackMachineSpread(vk volKey, r *rack, machines map[string][]*Node, di
|
||||
// shard (free slot, under SameRackCount), so a capped machine is skipped
|
||||
// rather than settled on and the move dropped.
|
||||
destHost, ok := pickTarget(machineKeys, shardsPerMachine, maxPerMachine, nil,
|
||||
func(h string) bool { return h != pm.src.host && pickBestNodeForVolume(machines[h], vk, rp) != nil },
|
||||
func(string) bool { return true })
|
||||
func(h string) bool {
|
||||
return h != pm.src.host && pickBestNodeForVolumeOnDisk(machines[h], vk, rp, diskType) != nil
|
||||
},
|
||||
func(string) bool { return true },
|
||||
func(h string) int {
|
||||
n := 0
|
||||
for _, node := range machines[h] {
|
||||
n += freeSlotsForType(node, diskType)
|
||||
}
|
||||
return n
|
||||
})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
destNode := pickBestNodeForVolume(machines[destHost], vk, rp)
|
||||
destNode := pickBestNodeForVolumeOnDisk(machines[destHost], vk, rp, diskType)
|
||||
if destNode == nil {
|
||||
continue
|
||||
}
|
||||
@@ -749,13 +811,14 @@ func balanceShardTypeAcrossNodes(vk volKey, r *rack, diskType string, dataShards
|
||||
var moves []*move
|
||||
for _, pm := range toMove {
|
||||
destID, ok := pickTarget(nodeKeys, shardsPerNode, maxPerNode, antiAffinity,
|
||||
func(n string) bool { return n != pm.src.id && r.nodes[n].freeSlots > 0 },
|
||||
func(n string) bool { return n != pm.src.id && freeSlotsForType(r.nodes[n], diskType) > 0 },
|
||||
func(n string) bool {
|
||||
if rp != nil && rp.SameRackCount > 0 {
|
||||
return nodeShardCount[n] < rp.SameRackCount
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
func(n string) int { return freeSlotsForType(r.nodes[n], diskType) })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -967,12 +1030,17 @@ func shardsByGroup(vk volKey, nodes map[string]*Node, dataShards int, key func(*
|
||||
|
||||
// pickTarget selects a destination key with room for another shard of a type, in
|
||||
// two passes: first excluding anti-affinity targets, then any valid target. Among
|
||||
// valid targets it prefers the fewest shards of this type; ties break on sorted
|
||||
// key order, so selection is deterministic.
|
||||
func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTarget int, antiAffinity map[string]bool, hasFreeSlots, withinLimit func(string) bool) (string, bool) {
|
||||
// valid targets it prefers the fewest shards of this type (even spread). Count
|
||||
// ties break on remaining freeSlots, not sorted key order: otherwise 14/4
|
||||
// leftovers always land on the first rack/machine/node ids and the extra shards
|
||||
// of 4+4+3+3 never walk. Equal freeSlots still keep the first seen key so a
|
||||
// single run stays deterministic. Node-in-rack Place remainders (after even) are
|
||||
// freeSlots then liveShards — see pickNodeInRackEligible.
|
||||
func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTarget int, antiAffinity map[string]bool, hasFreeSlots, withinLimit func(string) bool, freeSlots func(string) int) (string, bool) {
|
||||
try := func(skipAnti bool) (string, bool) {
|
||||
best := ""
|
||||
bestCount := maxPerTarget + 1
|
||||
bestFree := -1
|
||||
for _, c := range candidates {
|
||||
if skipAnti && antiAffinity[c] {
|
||||
continue
|
||||
@@ -986,8 +1054,13 @@ func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTar
|
||||
if !withinLimit(c) {
|
||||
continue
|
||||
}
|
||||
if cnt := len(shardsPerTarget[c]); cnt < bestCount {
|
||||
best, bestCount = c, cnt
|
||||
cnt := len(shardsPerTarget[c])
|
||||
free := 0
|
||||
if freeSlots != nil {
|
||||
free = freeSlots(c)
|
||||
}
|
||||
if best == "" || cnt < bestCount || (cnt == bestCount && free > bestFree) {
|
||||
best, bestCount, bestFree = c, cnt, free
|
||||
}
|
||||
}
|
||||
return best, best != ""
|
||||
@@ -1169,6 +1242,19 @@ func volumeShardCount(node *Node, vk volKey) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// nodeLiveShardCount is the disk-level EC shard cardinality (master EcShards),
|
||||
// not the UI "EC volumes" count (len of the volume map).
|
||||
func nodeLiveShardCount(n *Node) int {
|
||||
if n == nil {
|
||||
return 0
|
||||
}
|
||||
total := 0
|
||||
for _, d := range n.disks {
|
||||
total += d.shardCount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func nodeInRackHoldingShard(nodes map[string]*Node, rackID string, vk volKey, shardID int) *Node {
|
||||
var inRack []*Node
|
||||
for _, id := range sortedNodeKeys(nodes) {
|
||||
|
||||
@@ -90,6 +90,71 @@ func TestPickBestDiskOnNode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPickBestNodeForVolume(t *testing.T) {
|
||||
const vid = uint32(100)
|
||||
vk := volKey{collection: "c", vid: vid}
|
||||
|
||||
t.Run("more free slots wins the count tie", func(t *testing.T) {
|
||||
topo := NewTopology()
|
||||
thin := topo.AddNode("n1", "dc1", "dc1:r1", 5)
|
||||
thin.AddDisk(1, "", 5, 0)
|
||||
fat := topo.AddNode("n2", "dc1", "dc1:r1", 50)
|
||||
fat.AddDisk(1, "", 50, 0)
|
||||
if got := pickBestNodeForVolume([]*Node{thin, fat}, vk, nil); got != fat {
|
||||
t.Errorf("got %v, want n2 (more free slots)", got.id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fewer live shards wins the free tie", func(t *testing.T) {
|
||||
topo := NewTopology()
|
||||
idle := topo.AddNode("n1", "dc1", "dc1:r1", 10)
|
||||
idle.AddDisk(1, "", 10, 0)
|
||||
busy := topo.AddNode("n2", "dc1", "dc1:r1", 10)
|
||||
busy.AddDisk(1, "", 10, 9)
|
||||
if got := pickBestNodeForVolume([]*Node{busy, idle}, vk, nil); got != idle {
|
||||
t.Errorf("got %v, want n1 (fewer live shards)", got.id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("volume spread beats free slots", func(t *testing.T) {
|
||||
topo := NewTopology()
|
||||
holding := topo.AddNode("n1", "dc1", "dc1:r1", 100)
|
||||
holding.AddDisk(1, "", 100, 1)
|
||||
holding.AddShards(vid, "c", 1, bits(0))
|
||||
empty := topo.AddNode("n2", "dc1", "dc1:r1", 1)
|
||||
empty.AddDisk(1, "", 1, 0)
|
||||
if got := pickBestNodeForVolume([]*Node{holding, empty}, vk, nil); got != empty {
|
||||
t.Errorf("got %v, want n2 (volume spread first)", got.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanSkipsDestinationsWithoutMatchingDiskType(t *testing.T) {
|
||||
topo := NewTopology()
|
||||
src := topo.AddNode("node1", "dc1", "dc1:rack1", 0)
|
||||
src.AddDisk(0, "ssd", 0, 14)
|
||||
src.AddShards(100, "col1", 0, bits(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13))
|
||||
// Aggregate capacity alone would make the hdd-only node look like the
|
||||
// roomiest destination; none of its slots can take an ssd shard.
|
||||
wrong := topo.AddNode("node2", "dc1", "dc1:rack2", 600)
|
||||
wrong.AddDisk(1, "hdd", 600, 0)
|
||||
right := topo.AddNode("node3", "dc1", "dc1:rack3", 600)
|
||||
right.AddDisk(1, "ssd", 600, 0)
|
||||
|
||||
moves := Plan(topo, Options{ImbalanceThreshold: 0.01, DiskType: "ssd", Ratio: ratio(10, 4)})
|
||||
if len(moves) == 0 {
|
||||
t.Fatal("expected moves")
|
||||
}
|
||||
for _, m := range moves {
|
||||
if m.TargetNode == wrong.id {
|
||||
t.Errorf("shard %d targeted the hdd-only node", m.ShardID)
|
||||
}
|
||||
if m.TargetNode == right.id && m.TargetDisk != 1 {
|
||||
t.Errorf("shard %d landed on disk %d, want 1", m.ShardID, m.TargetDisk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceDiskAttribution(t *testing.T) {
|
||||
shardsByDisk := map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}
|
||||
shardToDisk := map[int]uint32{}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user