itsi 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +524 -44
  3. data/Rakefile +22 -33
  4. data/crates/itsi_error/Cargo.toml +2 -0
  5. data/crates/itsi_error/src/from.rs +70 -0
  6. data/crates/itsi_error/src/lib.rs +10 -37
  7. data/crates/itsi_instrument_entry/Cargo.toml +15 -0
  8. data/crates/itsi_instrument_entry/src/lib.rs +31 -0
  9. data/crates/itsi_rb_helpers/Cargo.toml +2 -0
  10. data/crates/itsi_rb_helpers/src/heap_value.rs +121 -0
  11. data/crates/itsi_rb_helpers/src/lib.rs +90 -10
  12. data/crates/itsi_scheduler/Cargo.toml +9 -1
  13. data/crates/itsi_scheduler/extconf.rb +1 -1
  14. data/crates/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
  15. data/crates/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
  16. data/crates/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
  17. data/crates/itsi_scheduler/src/itsi_scheduler.rs +308 -0
  18. data/crates/itsi_scheduler/src/lib.rs +31 -10
  19. data/crates/itsi_server/Cargo.toml +14 -2
  20. data/crates/itsi_server/extconf.rb +1 -1
  21. data/crates/itsi_server/src/body_proxy/big_bytes.rs +104 -0
  22. data/crates/itsi_server/src/body_proxy/itsi_body_proxy.rs +122 -0
  23. data/crates/itsi_server/src/body_proxy/mod.rs +2 -0
  24. data/crates/itsi_server/src/lib.rs +58 -7
  25. data/crates/itsi_server/src/request/itsi_request.rs +238 -104
  26. data/crates/itsi_server/src/response/itsi_response.rs +347 -0
  27. data/crates/itsi_server/src/response/mod.rs +1 -0
  28. data/crates/itsi_server/src/server/bind.rs +50 -20
  29. data/crates/itsi_server/src/server/bind_protocol.rs +37 -0
  30. data/crates/itsi_server/src/server/io_stream.rs +104 -0
  31. data/crates/itsi_server/src/server/itsi_ca/itsi_ca.crt +11 -30
  32. data/crates/itsi_server/src/server/itsi_ca/itsi_ca.key +3 -50
  33. data/crates/itsi_server/src/server/itsi_server.rs +196 -134
  34. data/crates/itsi_server/src/server/lifecycle_event.rs +9 -0
  35. data/crates/itsi_server/src/server/listener.rs +184 -127
  36. data/crates/itsi_server/src/server/mod.rs +7 -1
  37. data/crates/itsi_server/src/server/process_worker.rs +196 -0
  38. data/crates/itsi_server/src/server/serve_strategy/cluster_mode.rs +254 -0
  39. data/crates/itsi_server/src/server/serve_strategy/mod.rs +27 -0
  40. data/crates/itsi_server/src/server/serve_strategy/single_mode.rs +241 -0
  41. data/crates/itsi_server/src/server/signal.rs +70 -0
  42. data/crates/itsi_server/src/server/thread_worker.rs +368 -0
  43. data/crates/itsi_server/src/server/tls.rs +42 -28
  44. data/crates/itsi_tracing/Cargo.toml +4 -0
  45. data/crates/itsi_tracing/src/lib.rs +36 -6
  46. data/gems/scheduler/Cargo.lock +219 -23
  47. data/gems/scheduler/Rakefile +7 -1
  48. data/gems/scheduler/ext/itsi_error/Cargo.toml +2 -0
  49. data/gems/scheduler/ext/itsi_error/src/from.rs +70 -0
  50. data/gems/scheduler/ext/itsi_error/src/lib.rs +10 -37
  51. data/gems/scheduler/ext/itsi_instrument_entry/Cargo.toml +15 -0
  52. data/gems/scheduler/ext/itsi_instrument_entry/src/lib.rs +31 -0
  53. data/gems/scheduler/ext/itsi_rb_helpers/Cargo.toml +2 -0
  54. data/gems/scheduler/ext/itsi_rb_helpers/src/heap_value.rs +121 -0
  55. data/gems/scheduler/ext/itsi_rb_helpers/src/lib.rs +90 -10
  56. data/gems/scheduler/ext/itsi_scheduler/Cargo.toml +9 -1
  57. data/gems/scheduler/ext/itsi_scheduler/extconf.rb +1 -1
  58. data/gems/scheduler/ext/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
  59. data/gems/scheduler/ext/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
  60. data/gems/scheduler/ext/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
  61. data/gems/scheduler/ext/itsi_scheduler/src/itsi_scheduler.rs +308 -0
  62. data/gems/scheduler/ext/itsi_scheduler/src/lib.rs +31 -10
  63. data/gems/scheduler/ext/itsi_server/Cargo.toml +41 -0
  64. data/gems/scheduler/ext/itsi_server/extconf.rb +6 -0
  65. data/gems/scheduler/ext/itsi_server/src/body_proxy/big_bytes.rs +104 -0
  66. data/gems/scheduler/ext/itsi_server/src/body_proxy/itsi_body_proxy.rs +122 -0
  67. data/gems/scheduler/ext/itsi_server/src/body_proxy/mod.rs +2 -0
  68. data/gems/scheduler/ext/itsi_server/src/lib.rs +103 -0
  69. data/gems/scheduler/ext/itsi_server/src/request/itsi_request.rs +277 -0
  70. data/gems/scheduler/ext/itsi_server/src/request/mod.rs +1 -0
  71. data/gems/scheduler/ext/itsi_server/src/response/itsi_response.rs +347 -0
  72. data/gems/scheduler/ext/itsi_server/src/response/mod.rs +1 -0
  73. data/gems/scheduler/ext/itsi_server/src/server/bind.rs +168 -0
  74. data/gems/scheduler/ext/itsi_server/src/server/bind_protocol.rs +37 -0
  75. data/gems/scheduler/ext/itsi_server/src/server/io_stream.rs +104 -0
  76. data/gems/scheduler/ext/itsi_server/src/server/itsi_ca/itsi_ca.crt +13 -0
  77. data/gems/scheduler/ext/itsi_server/src/server/itsi_ca/itsi_ca.key +5 -0
  78. data/gems/scheduler/ext/itsi_server/src/server/itsi_server.rs +244 -0
  79. data/gems/scheduler/ext/itsi_server/src/server/lifecycle_event.rs +9 -0
  80. data/gems/scheduler/ext/itsi_server/src/server/listener.rs +275 -0
  81. data/gems/scheduler/ext/itsi_server/src/server/mod.rs +11 -0
  82. data/gems/scheduler/ext/itsi_server/src/server/process_worker.rs +196 -0
  83. data/gems/scheduler/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +254 -0
  84. data/gems/scheduler/ext/itsi_server/src/server/serve_strategy/mod.rs +27 -0
  85. data/gems/scheduler/ext/itsi_server/src/server/serve_strategy/single_mode.rs +241 -0
  86. data/gems/scheduler/ext/itsi_server/src/server/signal.rs +70 -0
  87. data/gems/scheduler/ext/itsi_server/src/server/thread_worker.rs +368 -0
  88. data/gems/scheduler/ext/itsi_server/src/server/tls.rs +152 -0
  89. data/gems/scheduler/ext/itsi_tracing/Cargo.toml +4 -0
  90. data/gems/scheduler/ext/itsi_tracing/src/lib.rs +36 -6
  91. data/gems/scheduler/itsi-scheduler.gemspec +2 -3
  92. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  93. data/gems/scheduler/lib/itsi/scheduler.rb +137 -1
  94. data/gems/scheduler/test/helpers/test_helper.rb +24 -0
  95. data/gems/scheduler/test/test_active_record.rb +158 -0
  96. data/gems/scheduler/test/test_address_resolve.rb +23 -0
  97. data/gems/scheduler/test/test_block_unblock.rb +229 -0
  98. data/gems/scheduler/test/test_file_io.rb +193 -0
  99. data/gems/scheduler/test/test_itsi_scheduler.rb +24 -1
  100. data/gems/scheduler/test/test_kernel_sleep.rb +91 -0
  101. data/gems/scheduler/test/test_nested_fibers.rb +286 -0
  102. data/gems/scheduler/test/test_network_io.rb +274 -0
  103. data/gems/scheduler/test/test_process_wait.rb +26 -0
  104. data/gems/server/exe/itsi +88 -28
  105. data/gems/server/ext/itsi_error/Cargo.toml +2 -0
  106. data/gems/server/ext/itsi_error/src/from.rs +70 -0
  107. data/gems/server/ext/itsi_error/src/lib.rs +10 -37
  108. data/gems/server/ext/itsi_instrument_entry/Cargo.toml +15 -0
  109. data/gems/server/ext/itsi_instrument_entry/src/lib.rs +31 -0
  110. data/gems/server/ext/itsi_rb_helpers/Cargo.toml +2 -0
  111. data/gems/server/ext/itsi_rb_helpers/src/heap_value.rs +121 -0
  112. data/gems/server/ext/itsi_rb_helpers/src/lib.rs +90 -10
  113. data/gems/server/ext/itsi_scheduler/Cargo.toml +24 -0
  114. data/gems/server/ext/itsi_scheduler/extconf.rb +6 -0
  115. data/gems/server/ext/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
  116. data/gems/server/ext/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
  117. data/gems/server/ext/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
  118. data/gems/server/ext/itsi_scheduler/src/itsi_scheduler.rs +308 -0
  119. data/gems/server/ext/itsi_scheduler/src/lib.rs +38 -0
  120. data/gems/server/ext/itsi_server/Cargo.toml +14 -2
  121. data/gems/server/ext/itsi_server/extconf.rb +1 -1
  122. data/gems/server/ext/itsi_server/src/body_proxy/big_bytes.rs +104 -0
  123. data/gems/server/ext/itsi_server/src/body_proxy/itsi_body_proxy.rs +122 -0
  124. data/gems/server/ext/itsi_server/src/body_proxy/mod.rs +2 -0
  125. data/gems/server/ext/itsi_server/src/lib.rs +58 -7
  126. data/gems/server/ext/itsi_server/src/request/itsi_request.rs +238 -104
  127. data/gems/server/ext/itsi_server/src/response/itsi_response.rs +347 -0
  128. data/gems/server/ext/itsi_server/src/response/mod.rs +1 -0
  129. data/gems/server/ext/itsi_server/src/server/bind.rs +50 -20
  130. data/gems/server/ext/itsi_server/src/server/bind_protocol.rs +37 -0
  131. data/gems/server/ext/itsi_server/src/server/io_stream.rs +104 -0
  132. data/gems/server/ext/itsi_server/src/server/itsi_ca/itsi_ca.crt +11 -30
  133. data/gems/server/ext/itsi_server/src/server/itsi_ca/itsi_ca.key +3 -50
  134. data/gems/server/ext/itsi_server/src/server/itsi_server.rs +196 -134
  135. data/gems/server/ext/itsi_server/src/server/lifecycle_event.rs +9 -0
  136. data/gems/server/ext/itsi_server/src/server/listener.rs +184 -127
  137. data/gems/server/ext/itsi_server/src/server/mod.rs +7 -1
  138. data/gems/server/ext/itsi_server/src/server/process_worker.rs +196 -0
  139. data/gems/server/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +254 -0
  140. data/gems/server/ext/itsi_server/src/server/serve_strategy/mod.rs +27 -0
  141. data/gems/server/ext/itsi_server/src/server/serve_strategy/single_mode.rs +241 -0
  142. data/gems/server/ext/itsi_server/src/server/signal.rs +70 -0
  143. data/gems/server/ext/itsi_server/src/server/thread_worker.rs +368 -0
  144. data/gems/server/ext/itsi_server/src/server/tls.rs +42 -28
  145. data/gems/server/ext/itsi_tracing/Cargo.toml +4 -0
  146. data/gems/server/ext/itsi_tracing/src/lib.rs +36 -6
  147. data/gems/server/itsi-server.gemspec +4 -5
  148. data/gems/server/lib/itsi/request.rb +30 -14
  149. data/gems/server/lib/itsi/server/rack/handler/itsi.rb +25 -0
  150. data/gems/server/lib/itsi/server/scheduler_mode.rb +6 -0
  151. data/gems/server/lib/itsi/server/version.rb +1 -1
  152. data/gems/server/lib/itsi/server.rb +82 -2
  153. data/gems/server/lib/itsi/signals.rb +23 -0
  154. data/gems/server/lib/itsi/stream_io.rb +38 -0
  155. data/gems/server/test/test_helper.rb +2 -0
  156. data/gems/server/test/test_itsi_server.rb +1 -1
  157. data/lib/itsi/version.rb +1 -1
  158. data/tasks.txt +18 -0
  159. metadata +102 -12
  160. data/crates/itsi_server/src/server/transfer_protocol.rs +0 -23
  161. data/crates/itsi_server/src/stream_writer/mod.rs +0 -21
  162. data/gems/scheduler/test/test_helper.rb +0 -6
  163. data/gems/server/ext/itsi_server/src/server/transfer_protocol.rs +0 -23
  164. data/gems/server/ext/itsi_server/src/stream_writer/mod.rs +0 -21
@@ -0,0 +1,152 @@
1
+ use base64::{engine::general_purpose, Engine as _};
2
+ use itsi_error::Result;
3
+ use itsi_tracing::{info, warn};
4
+ use rcgen::{CertificateParams, DnType, KeyPair, SanType};
5
+ use rustls_pemfile::{certs, pkcs8_private_keys};
6
+ use std::{collections::HashMap, fs, io::BufReader};
7
+ use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig};
8
+
9
+ const ITS_CA_CERT: &str = include_str!("./itsi_ca/itsi_ca.crt");
10
+ const ITS_CA_KEY: &str = include_str!("./itsi_ca/itsi_ca.key");
11
+
12
+ // Generates a TLS configuration based on either :
13
+ // * Input "cert" and "key" options (either paths or Base64-encoded strings) or
14
+ // * Performs automatic certificate generation/retrieval. Generated certs use an internal self-signed Isti CA.
15
+ // If a non-local host or optional domain parameter is provided,
16
+ // an automated certificate will attempt to be fetched using let's encrypt.
17
+ pub fn configure_tls(host: &str, query_params: &HashMap<String, String>) -> Result<ServerConfig> {
18
+ info!("TLS Options {:?}", query_params);
19
+ let (certs, key) = if let (Some(cert_path), Some(key_path)) =
20
+ (query_params.get("cert"), query_params.get("key"))
21
+ {
22
+ // Load from file or Base64
23
+ let certs = load_certs(cert_path);
24
+ let key = load_private_key(key_path);
25
+ (certs, key)
26
+ } else {
27
+ let domains_param = query_params
28
+ .get("domains")
29
+ .map(|v| v.split(',').map(String::from).collect());
30
+ let host_string = host.to_string();
31
+ let domains = domains_param.or_else(|| {
32
+ if host_string != "localhost" {
33
+ Some(vec![host_string])
34
+ } else {
35
+ None
36
+ }
37
+ });
38
+
39
+ if let Some(domains) = domains {
40
+ retrieve_acme_cert(domains)?
41
+ } else {
42
+ generate_ca_signed_cert(vec![host.to_owned()])?
43
+ }
44
+ };
45
+
46
+ let mut config = ServerConfig::builder()
47
+ .with_safe_defaults()
48
+ .with_no_client_auth()
49
+ .with_single_cert(certs, key)
50
+ .expect("Failed to build TLS config");
51
+
52
+ config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
53
+ Ok(config)
54
+ }
55
+
56
+ pub fn load_certs(path: &str) -> Vec<Certificate> {
57
+ let data = if let Some(stripped) = path.strip_prefix("base64:") {
58
+ general_purpose::STANDARD
59
+ .decode(stripped)
60
+ .expect("Invalid base64 certificate")
61
+ } else {
62
+ fs::read(path).expect("Failed to read certificate file")
63
+ };
64
+
65
+ if data.starts_with(b"-----BEGIN ") {
66
+ let mut reader = BufReader::new(&data[..]);
67
+ let certs_der: Vec<Vec<u8>> = certs(&mut reader)
68
+ .map(|r| {
69
+ r.map(|der| der.as_ref().to_vec())
70
+ .map_err(itsi_error::ItsiError::from)
71
+ })
72
+ .collect::<Result<_>>()
73
+ .expect("Failed to parse certificate file");
74
+ certs_der.into_iter().map(Certificate).collect()
75
+ } else {
76
+ vec![Certificate(data)]
77
+ }
78
+ }
79
+
80
+ /// Loads a private key from a file or Base64.
81
+ pub fn load_private_key(path: &str) -> PrivateKey {
82
+ let key_data = if let Some(stripped) = path.strip_prefix("base64:") {
83
+ general_purpose::STANDARD
84
+ .decode(stripped)
85
+ .expect("Invalid base64 private key")
86
+ } else {
87
+ fs::read(path).expect("Failed to read private key file")
88
+ };
89
+
90
+ if key_data.starts_with(b"-----BEGIN ") {
91
+ let mut reader = BufReader::new(&key_data[..]);
92
+ let keys: Vec<Vec<u8>> = pkcs8_private_keys(&mut reader)
93
+ .map(|r| {
94
+ r.map(|key| key.secret_pkcs8_der().to_vec())
95
+ .map_err(itsi_error::ItsiError::from)
96
+ })
97
+ .collect::<Result<_>>()
98
+ .expect("Failed to parse private key");
99
+ if !keys.is_empty() {
100
+ return PrivateKey(keys[0].clone());
101
+ }
102
+ }
103
+ PrivateKey(key_data)
104
+ }
105
+
106
+ pub fn generate_ca_signed_cert(domains: Vec<String>) -> Result<(Vec<Certificate>, PrivateKey)> {
107
+ info!("Generating New Itsi CA - Self signed Certificate. Use `itsi ca export` to export the CA certificate for import into your local trust store.");
108
+
109
+ let ca_kp = KeyPair::from_pem(ITS_CA_KEY).expect("Failed to load embedded CA key");
110
+ let ca_cert = CertificateParams::from_ca_cert_pem(ITS_CA_CERT)
111
+ .expect("Failed to parse embedded CA certificate")
112
+ .self_signed(&ca_kp)
113
+ .expect("Failed to self-sign embedded CA cert");
114
+
115
+ let ee_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
116
+ let mut ee_params = CertificateParams::default();
117
+
118
+ info!(
119
+ "Generated certificate will be valid for domains {:?}",
120
+ domains
121
+ );
122
+ use std::net::IpAddr;
123
+
124
+ ee_params.subject_alt_names = domains
125
+ .iter()
126
+ .map(|domain| {
127
+ if let Ok(ip) = domain.parse::<IpAddr>() {
128
+ SanType::IpAddress(ip)
129
+ } else {
130
+ SanType::DnsName(domain.clone().try_into().unwrap())
131
+ }
132
+ })
133
+ .collect();
134
+
135
+ ee_params
136
+ .distinguished_name
137
+ .push(DnType::CommonName, domains[0].clone());
138
+
139
+ ee_params.use_authority_key_identifier_extension = true;
140
+
141
+ let ee_cert = ee_params.signed_by(&ee_key, &ca_cert, &ca_kp).unwrap();
142
+ let ee_cert_der = ee_cert.der().to_vec();
143
+ let ee_cert = Certificate(ee_cert_der);
144
+ let ca_cert = Certificate(ca_cert.der().to_vec());
145
+ Ok((vec![ee_cert, ca_cert], PrivateKey(ee_key.serialize_der())))
146
+ }
147
+
148
+ /// TODO: Retrieves an ACME certificate for a given domain.
149
+ pub fn retrieve_acme_cert(domains: Vec<String>) -> Result<(Vec<Certificate>, PrivateKey)> {
150
+ warn!("Retrieving ACME cert for {}", domains.join(", "));
151
+ generate_ca_signed_cert(domains)
152
+ }
@@ -9,4 +9,8 @@ tracing-subscriber = { version = "0.3.19", features = [
9
9
  "env-filter",
10
10
  "std",
11
11
  "fmt",
12
+ "json",
13
+ "ansi",
12
14
  ] }
15
+ tracing-attributes = "0.1"
16
+ atty = "0.2.14"
@@ -1,11 +1,41 @@
1
+ use std::env;
2
+
3
+ use atty::{Stream, is};
1
4
  pub use tracing::{debug, error, info, trace, warn};
2
- use tracing_subscriber::{EnvFilter, fmt};
5
+ pub use tracing_attributes::instrument; // Explicitly export from tracing-attributes
6
+ use tracing_subscriber::{
7
+ EnvFilter,
8
+ fmt::{self, format},
9
+ };
3
10
 
11
+ #[instrument]
4
12
  pub fn init() {
5
- let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
6
- let format = fmt::format().with_level(true).with_target(false).compact();
7
- tracing_subscriber::fmt()
8
- .with_env_filter(env_filter)
13
+ let env_filter = EnvFilter::builder()
14
+ .with_env_var("ITSI_LOG")
15
+ .try_from_env()
16
+ .unwrap_or_else(|_| EnvFilter::new("info"));
17
+
18
+ let format = fmt::format()
19
+ .compact()
20
+ .with_file(false)
21
+ .with_level(true)
22
+ .with_line_number(false)
23
+ .with_source_location(false)
24
+ .with_target(false)
25
+ .with_thread_ids(false);
26
+
27
+ let is_tty = is(Stream::Stdout);
28
+
29
+ let subscriber = tracing_subscriber::fmt()
9
30
  .event_format(format)
10
- .init();
31
+ .with_env_filter(env_filter);
32
+
33
+ if (is_tty && env::var("ITSI_LOG_PLAIN").is_err()) || env::var("ITSI_LOG_ANSI").is_ok() {
34
+ subscriber.with_ansi(true).init();
35
+ } else {
36
+ subscriber
37
+ .fmt_fields(format::JsonFields::default())
38
+ .event_format(fmt::format().json())
39
+ .init();
40
+ }
11
41
  }
@@ -12,8 +12,8 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Itsi Scheduler - A light-weight Fiber Scheduler implementation for Ruby"
13
13
  spec.homepage = "https://itsi.fyi"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.1.0"
16
- spec.required_rubygems_version = ">= 3.3.11"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+ spec.required_rubygems_version = ">= 3.1.11"
17
17
 
18
18
  spec.metadata["homepage_uri"] = spec.homepage
19
19
  spec.metadata["source_code_uri"] = "https://github.com/wouterken/itsi/scheduler"
@@ -35,7 +35,6 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  # Uncomment to register a new dependency of your gem
37
37
  # spec.add_dependency "example-gem", "~> 1.0"
38
- spec.add_dependency "libclang", "~> 14.0"
39
38
  spec.add_dependency "rb_sys", "~> 0.9.91"
40
39
 
41
40
  # For more information and examples about making a new gem, check out our
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Itsi
4
4
  class Scheduler
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.3"
6
6
  end
7
7
  end
@@ -6,6 +6,142 @@ require_relative "scheduler/itsi_scheduler"
6
6
  module Itsi
7
7
  class Scheduler
8
8
  class Error < StandardError; end
9
- # Your code goes here...
9
+
10
+ def self.resume_token
11
+ @resume_token ||= 0
12
+ @resume_token += 1
13
+ end
14
+
15
+ def initialize
16
+ @join_waiters = {}.compare_by_identity
17
+ @token_map = {}.compare_by_identity
18
+ @resume_tokens = {}.compare_by_identity
19
+ @unblocked = [[], []]
20
+ @unblock_idx = 0
21
+ @unblocked_mux = Mutex.new
22
+ @resume_fiber = method(:resume_fiber).to_proc
23
+ @resume_fiber_with_readiness = method(:resume_fiber_with_readiness).to_proc
24
+ @resume_blocked = method(:resume_blocked).to_proc
25
+ end
26
+
27
+ def block(_, timeout, fiber = Fiber.current, token = Scheduler.resume_token)
28
+ @join_waiters[fiber] = true
29
+
30
+ start_timer(timeout, token) if timeout
31
+ @resume_tokens[token] = fiber
32
+ @token_map[fiber] = token
33
+ Fiber.yield
34
+ ensure
35
+ @resume_tokens.delete(token)
36
+ @token_map.delete(fiber)
37
+ @join_waiters.delete(fiber)
38
+ end
39
+
40
+ # Register an IO waiter.
41
+ # This will get resumed by our scheduler inside the call to
42
+ # fetch_events.
43
+ def io_wait(io, events, duration)
44
+ fiber = Fiber.current
45
+ token = Scheduler.resume_token
46
+ readiness = register_io_wait(io.fileno, events, duration, token)
47
+ readiness || block(nil, duration, fiber, token)
48
+ end
49
+
50
+ def unblock(_blocker, fiber)
51
+ @unblocked_mux.synchronize do
52
+ @unblocked[@unblock_idx] << fiber
53
+ end
54
+ wake
55
+ end
56
+
57
+ def kernel_sleep(duration)
58
+ block nil, duration
59
+ end
60
+
61
+ def tick
62
+ events = fetch_due_events
63
+ timers = fetch_due_timers
64
+ unblocked = switch_unblock_batch
65
+ events&.each(&@resume_fiber_with_readiness)
66
+ unblocked.each(&@resume_blocked)
67
+ unblocked.clear
68
+ timers&.each(&@resume_fiber)
69
+ end
70
+
71
+ def resume_fiber(token)
72
+ if (fiber = @resume_tokens.delete(token))
73
+ fiber.resume
74
+ end
75
+ rescue StandardError => e
76
+ warn "Failed to resume fiber #{fiber}: #{e.message}"
77
+ end
78
+
79
+ def resume_fiber_with_readiness((token, readiness))
80
+ if (fiber = @resume_tokens.delete(token))
81
+ fiber.resume(readiness)
82
+ end
83
+ rescue StandardError => e
84
+ warn "Failed to resume fiber #{fiber}: #{e.message}"
85
+ end
86
+
87
+ def resume_blocked(fiber)
88
+ if (token = @token_map[fiber])
89
+ resume_fiber(token)
90
+ elsif fiber.alive?
91
+ fiber.resume
92
+ end
93
+ end
94
+
95
+ def switch_unblock_batch
96
+ @unblocked_mux.synchronize do
97
+ current = @unblocked[@unblock_idx]
98
+ @unblock_idx = (@unblock_idx + 1) % 2
99
+ current
100
+ end
101
+ end
102
+
103
+ # Yields upwards to the scheduler, with an intention to
104
+ # resume the fiber that yielded ASAP.
105
+ def yield
106
+ kernel_sleep(0) if work?
107
+ end
108
+
109
+ # Keep running until we've got no timers we're awaiting, no pending IO, no temporary yields,
110
+ # no pending unblocks.
111
+ def work?
112
+ !@unblocked[@unblock_idx].empty? || !@join_waiters.empty? || has_pending_io?
113
+ end
114
+
115
+ # Run until no more work needs doing.
116
+ def run
117
+ tick while work?
118
+ debug "Exit Scheduler"
119
+ end
120
+
121
+ # Hook invoked at the end of the thread.
122
+ # Will start our scheduler's Reactor.
123
+ def scheduler_close
124
+ run
125
+ ensure
126
+ @closed ||= true
127
+ freeze
128
+ end
129
+
130
+ # Need to defer to Process::Status rather than our extension
131
+ # as we don't have a means of creating our own Process::Status.
132
+ def process_wait(pid, flags)
133
+ Thread.new do
134
+ Process::Status.wait(pid, flags)
135
+ end.value
136
+ end
137
+
138
+ def closed?
139
+ @closed
140
+ end
141
+
142
+ # Spin up a new fiber and immediately resume it.
143
+ def fiber(&blk)
144
+ Fiber.new(blocking: false, &blk).tap(&:resume)
145
+ end
10
146
  end
11
147
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/reporters"
4
+ Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
5
+
6
+ require "itsi/scheduler"
7
+ require 'debug'
8
+ module Itsi::Scheduler::TestHelper
9
+ SchedulerClass = Itsi::Scheduler
10
+
11
+ def with_scheduler(join: true, report_on_exception: false)
12
+ Thread.new do
13
+ Thread.current.report_on_exception = report_on_exception
14
+ scheduler = SchedulerClass.new
15
+ Fiber.set_scheduler(scheduler)
16
+ Fiber.schedule do
17
+ yield scheduler
18
+ end
19
+ end.yield_self do |thread|
20
+ thread.join if join
21
+ thread
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ class TestActiveRecordFiberScheduler < Minitest::Test
6
+ include Itsi::Scheduler::TestHelper
7
+
8
+ # Set up an ActiveRecord connection to your PostgreSQL test database.
9
+ # Adjust the connection parameters as needed.
10
+ def setup
11
+ ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
12
+ ActiveRecord::Base.establish_connection(
13
+ adapter: "postgresql",
14
+ database: "fiber_scheduler_test",
15
+ pool: 2, # use a small pool to test contention scenarios
16
+ checkout_timeout: 5
17
+ )
18
+ end
19
+
20
+ # Disconnect after each test.
21
+ def teardown
22
+ ActiveRecord::Base.connection_pool.disconnect!
23
+ end
24
+
25
+ # Test a basic query execution inside a fiber.
26
+ def test_basic_query
27
+ result = nil
28
+
29
+ with_scheduler do |_scheduler|
30
+ Fiber.schedule do
31
+ result = ActiveRecord::Base.connection.select_value("SELECT 1")
32
+ end
33
+ end
34
+
35
+ # select_value returns a string from PG adapter so we compare with "1"
36
+ assert_equal "1", result.to_s
37
+ end
38
+
39
+ # Test running two queries concurrently in different fibers.
40
+ def test_concurrent_queries
41
+ results = []
42
+
43
+ with_scheduler do |_scheduler|
44
+ Fiber.schedule do
45
+ results << ActiveRecord::Base.connection.select_value("SELECT 1")
46
+ end
47
+
48
+ Fiber.schedule do
49
+ results << ActiveRecord::Base.connection.select_value("SELECT 2")
50
+ end
51
+ end
52
+
53
+ # Ensure that both queries have executed and returned the expected values.
54
+ results = results.map(&:to_s)
55
+ assert_includes results, "1"
56
+ assert_includes results, "2"
57
+ end
58
+
59
+ # Test a query that involves a short delay using PostgreSQL's pg_sleep.
60
+ def test_query_with_delay
61
+ result = nil
62
+
63
+ with_scheduler do |_scheduler|
64
+ Fiber.schedule do
65
+ # Introduce a 0.1 second delay.
66
+ ActiveRecord::Base.connection.execute("SELECT pg_sleep(0.1)")
67
+ result = ActiveRecord::Base.connection.select_value("SELECT 3")
68
+ end
69
+ end
70
+
71
+ assert_equal "3", result.to_s
72
+ end
73
+
74
+ # Test connection pool exhaustion by limiting the pool to one connection.
75
+ # Two fibers will attempt to get a connection concurrently.
76
+ def test_connection_pool_exhaustion
77
+ # Re-establish connection with a pool size of 1.
78
+ ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
79
+ ActiveRecord::Base.establish_connection(
80
+ adapter: "postgresql",
81
+ host: "localhost",
82
+ database: "fiber_scheduler_test",
83
+ pool: 1,
84
+ checkout_timeout: 0.25
85
+ )
86
+ # ActiveRecord::Base.connection_pool.disconnect!
87
+
88
+ results = []
89
+
90
+ with_scheduler do |_scheduler|
91
+ Fiber.schedule do
92
+ ActiveRecord::Base.connection_pool.with_connection(prevent_permanent_checkout: true) do
93
+ results << ActiveRecord::Base.connection.select_value("SELECT 1")
94
+ end
95
+ end
96
+
97
+ Fiber.schedule do
98
+ ActiveRecord::Base.connection_pool.with_connection(prevent_permanent_checkout: true) do
99
+ results << ActiveRecord::Base.connection.select_value("SELECT 2")
100
+ end
101
+ end
102
+ end
103
+
104
+ # Takes #{checkout_timeout} seconds between last
105
+ results = results.map(&:to_s)
106
+ assert_includes results, "1"
107
+ assert_includes results, "2"
108
+ end
109
+
110
+ # Test that after a Fiber finishes its work, its connection is automatically released.
111
+ def test_fiber_connection_release_after_completion
112
+ # Use the scheduler to run a fiber that checks out a connection and does a simple query.
113
+
114
+ with_scheduler do |_scheduler|
115
+ Fiber.schedule do
116
+ # This fiber checks out a connection to run a query.
117
+ ActiveRecord::Base.connection.select_value("SELECT 1")
118
+ # The fiber ends here.
119
+ end
120
+ end
121
+
122
+ # After the scheduler finishes, the fiber should have completed and released its connection.
123
+ # Now we attempt to checkout a connection manually. If the previous fiber's connection
124
+ # was not released, this would either time out or raise an error.
125
+ connection = ActiveRecord::Base.connection_pool.checkout
126
+ assert connection, "Expected to obtain a connection after fiber completion"
127
+ ActiveRecord::Base.connection_pool.checkin(connection)
128
+ end
129
+
130
+ # Test that a transaction works correctly when run inside a fiber.
131
+ # A temporary table is created, a record inserted and then queried before the transaction is rolled back.
132
+ def test_transaction_fiber
133
+ result = nil
134
+
135
+ with_scheduler do |_scheduler|
136
+ Fiber.schedule do
137
+ ActiveRecord::Base.transaction do
138
+ # Create a temporary table for testing.
139
+ ActiveRecord::Base.connection.execute(<<~SQL)
140
+ CREATE TEMP TABLE IF NOT EXISTS test_table (
141
+ id serial PRIMARY KEY,
142
+ name text
143
+ )
144
+ SQL
145
+
146
+ # Insert a record.
147
+ ActiveRecord::Base.connection.execute("INSERT INTO test_table (name) VALUES ('Alice')")
148
+ # Query the inserted record.
149
+ result = ActiveRecord::Base.connection.select_value("SELECT name FROM test_table LIMIT 1")
150
+ # Roll back the transaction to avoid leaving test data.
151
+ raise ActiveRecord::Rollback
152
+ end
153
+ end
154
+ end
155
+
156
+ assert_equal "Alice", result.to_s
157
+ end
158
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require 'debug'
3
+
4
+
5
+ class TestAddressResolve < Minitest::Test
6
+ include Itsi::Scheduler::TestHelper
7
+
8
+ def test_addess_resolve
9
+ results = []
10
+
11
+ with_scheduler do |_scheduler|
12
+ Fiber.schedule do
13
+ results << Addrinfo.getaddrinfo("www.ruby-lang.org", 80, nil, :STREAM)
14
+ end
15
+ Fiber.schedule do
16
+ results << Addrinfo.getaddrinfo("www.google.com", 80, nil, :STREAM)
17
+ end
18
+ end
19
+
20
+ assert results.all?{|results| results.find(&:ipv4?) }
21
+ assert results.all?{|results| results.find(&:ipv6?) }
22
+ end
23
+ end