├── LICENSE ├── README.md ├── composer.json ├── ludicrousdb.php ├── ludicrousdb ├── drop-ins │ ├── db-config.php │ ├── db-error.php │ └── db.php └── includes │ ├── class-ludicrousdb.php │ └── functions.php └── readme.txt /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LudicrousDB 2 | 3 | LudicrousDB is an advanced database interface for WordPress that supports replication, fail-over, load balancing, and partitioning, based on Automattic's HyperDB drop-in. 4 | 5 | ## 0. Installation 6 | 7 | ### Files 8 | 9 | Copy the main `ludicrousdb` plugin folder & its contents to either: 10 | 11 | * `wp-content/plugins/ludicrousdb/` 12 | * `wp-content/mu-plugins/ludicrousdb/` 13 | 14 | It does not matter which one; LudicrousDB will figure it out. The folder name should be exactly `ludicrousdb`. Be careful when you do "Download ZIP" from github and unzip. 15 | 16 | ### Drop-ins 17 | 18 | WordPress supports a few "drop-in" style plugins, used for advanced overriding of a few specific pieces of functionality. 19 | 20 | LudicrousDB includes 3 basic database drop-ins: 21 | 22 | * `db.php` <-> `wp-content/db.php` - Bootstrap for replacement `$wpdb` object 23 | * `db-error.php` <-> `wp-content/db-error.php` - Endpoint for fatal database error output to users 24 | * `db-config.php` <-> `ABSPATH/db-config.php` - For configuring your database environment 25 | 26 | You'll probably want to copy these files to their respective locations, and modify them once you're comfortable with what they do and how they work. 27 | 28 | ## 1. Configuration 29 | 30 | LudicrousDB can manage connections to a large number of databases. Queries are distributed to appropriate servers by mapping table names to datasets. 31 | 32 | A dataset is defined as a group of tables that are located in the same database. There may be similarly-named databases containing different tables on different servers. There may also be many replicas of a database on different servers. The term "dataset" removes any ambiguity. Consider a dataset as a group of tables that can be mirrored on many servers. 33 | 34 | Configuring LudicrousDB involves defining databases and datasets. Defining a database involves specifying the server connection details, the dataset it contains, and its capabilities and priorities for reading and writing. Defining a dataset involves specifying its exact table names or registering one or more callback functions that translate table names to datasets. 35 | 36 | ### Sample Configuration 1: Default Server 37 | 38 | This is the most basic way to add a server to LudicrousDB using only the required parameters: host, user, password, name. This adds the DB defined in wp-config.php as a read/write server for the 'global' dataset. (Every table is in 'global' by default.) 39 | 40 | ```php 41 | $wpdb->add_database( array( 42 | 'host' => DB_HOST, // If port is other than 3306, use host:port. 43 | 'user' => DB_USER, 44 | 'password' => DB_PASSWORD, 45 | 'name' => DB_NAME, 46 | ) ); 47 | ``` 48 | 49 | This adds the same server again, only this time it is configured as a replica. The last three parameters are set to the defaults but are shown for clarity. 50 | 51 | ```php 52 | $wpdb->add_database( array( 53 | 'host' => DB_HOST, // If port is other than 3306, use host:port. 54 | 'user' => DB_USER, 55 | 'password' => DB_PASSWORD, 56 | 'name' => DB_NAME, 57 | 'write' => 0, 58 | 'read' => 1, 59 | 'dataset' => 'global', 60 | 'timeout' => 0.2, 61 | ) ); 62 | ``` 63 | 64 | ### Sample Configuration 2: Partitioning 65 | 66 | This example shows a setup where the multisite blog tables have been separated from the global dataset. 67 | 68 | ```php 69 | $wpdb->add_database( array( 70 | 'host' => 'global.db.example.com', 71 | 'user' => 'globaluser', 72 | 'password' => 'globalpassword', 73 | 'name' => 'globaldb', 74 | ) ); 75 | 76 | $wpdb->add_database( array( 77 | 'host' => 'blog.db.example.com', 78 | 'user' => 'bloguser', 79 | 'password' => 'blogpassword', 80 | 'name' => 'blogdb', 81 | 'dataset' => 'blog', 82 | ) ); 83 | 84 | $wpdb->add_callback( 'my_db_callback' ); 85 | 86 | // Multisite blog tables are "{$base_prefix}{$blog_id}_*" 87 | function my_db_callback( $query, $wpdb ) { 88 | if ( preg_match("/^{$wpdb->base_prefix}\d+_/i", $wpdb->table) ) { 89 | return 'blog'; 90 | } 91 | } 92 | ``` 93 | 94 | ### Configuration Functions 95 | 96 | #### add_database() 97 | 98 | ```php 99 | $wpdb->add_database( $database ); 100 | ``` 101 | 102 | `$database` is an associative array with these parameters: 103 | 104 | ```php 105 | host (required) Hostname with optional :port. Default port is 3306. 106 | user (required) MySQL user name. 107 | password (required) MySQL user password. 108 | name (required) MySQL database name. 109 | read (optional) Whether server is readable. Default is 1 (readable). 110 | Also used to assign preference. See "Network topology". 111 | write (optional) Whether server is writable. Default is 1 (writable). 112 | Also used to assign preference in multi-primary mode. 113 | dataset (optional) Name of dataset. Default is 'global'. 114 | timeout (optional) Seconds to wait for TCP responsiveness. Default is 0.2 115 | lag_threshold (optional) The minimum lag on a replica in seconds before we consider it lagged. 116 | Set null to disable. When not set, the value of $wpdb->default_lag_threshold is used. 117 | ``` 118 | 119 | #### add_table() 120 | 121 | ```php 122 | $wpdb->add_table( $dataset, $table ); 123 | ``` 124 | 125 | `$dataset` and `$table` are strings. 126 | 127 | #### add_callback() 128 | 129 | ```php 130 | $wpdb->add_callback( $callback, $callback_group = 'dataset' ); 131 | ``` 132 | 133 | `$callback` is a callable function or method. `$callback_group` is the group of callbacks, this `$callback` belongs to. 134 | 135 | Callbacks are executed in the order in which they are registered until one of them returns something other than null. 136 | 137 | The default `$callback_group` is 'dataset'. Callback in this group will be called with two arguments and expected to compute a dataset or return null. 138 | 139 | ```php 140 | $dataset = $callback($table, &$wpdb); 141 | ``` 142 | 143 | Anything evaluating to false will cause the query to be aborted. 144 | 145 | For more complex setups, the callback may be used to overwrite properties of `$wpdb` or variables within `LudicrousDB::connect_db()`. If a callback returns an array, LudicrousDB will extract the array. It should be an associative array and it should include a `$dataset` value corresponding to a database added with `$wpdb->add_database()`. It may also include `$server`, which will be extracted to overwrite the parameters of each randomly selected database server prior to connection. This allows you to dynamically vary parameters such as the host, user, password, database name, lag_threshold and TCP check timeout. 146 | 147 | ## 2. Primary & Replica Databases 148 | 149 | A database definition can include 'read' and 'write' parameters. These operate as boolean switches but they are typically specified as integers. They allow or disallow use of the database for reading or writing. 150 | 151 | A primary database might be configured to allow reading and writing: 152 | 153 | ```php 154 | 'write' => 1, 155 | 'read' => 1, 156 | ``` 157 | 158 | while a replica would be allowed only to read: 159 | 160 | ```php 161 | 'write' => 0, 162 | 'read' => 1, 163 | ``` 164 | 165 | It might be advantageous to disallow reading from the primary, such as when there are many replicas available and the primary is very busy with writes. 166 | 167 | ```php 168 | 'write' => 1, 169 | 'read' => 0, 170 | ``` 171 | 172 | LudicrousDB tracks the tables that it has written since instantiation and sending subsequent read queries to the same server that received the write query. Thus a primary set up this way will still receive read queries, but only subsequent to writes. 173 | 174 | ## 3. Network topology / Datacenter awareness 175 | 176 | When your databases are located in separate physical locations there is typically an advantage to connecting to a nearby server instead of a more distant one. The read and write parameters can be used to place servers into logical groups of more or less preferred connections. Lower numbers indicate greater preference. 177 | 178 | This configuration instructs LudicrousDB to try reading from one of the local replicas at random. If that replica is unreachable or refuses the connection, the other replica will be tried, followed by the primary, and finally the remote replicas in random order. 179 | 180 | ```php 181 | Local replica 1: 'write' => 0, 'read' => 1, 182 | Local replica 2: 'write' => 0, 'read' => 1, 183 | Local primary: 'write' => 1, 'read' => 2, 184 | Remote replica 1: 'write' => 0, 'read' => 3, 185 | Remote replica 2: 'write' => 0, 'read' => 3, 186 | ``` 187 | 188 | In the other datacenter, the primary would be remote. We would take that into account while deciding where to send reads. Writes would always be sent to the primary, regardless of proximity. 189 | 190 | ```php 191 | Local replica 1: 'write' => 0, 'read' => 1, 192 | Local replica 2: 'write' => 0, 'read' => 1, 193 | Remote replica 1: 'write' => 0, 'read' => 2, 194 | Remote replica 2: 'write' => 0, 'read' => 2, 195 | Remote primary: 'write' => 1, 'read' => 3, 196 | ``` 197 | 198 | There are many ways to achieve different configurations in different locations. You can deploy different config files. You can write code to discover the web server's location, such as by inspecting `$_SERVER` or `php_uname()`, and compute the read/write parameters accordingly. 199 | 200 | ## 4. Replication Lag 201 | 202 | LudicrousDB accommodates replica lag by making decisions, based on the defined lag threshold. If the lag threshold is not set, it will ignore the replica lag. Otherwise, it will try to find a non-lagged replica, before connecting to a lagged one. 203 | 204 | A replica is considered lagged, if its lag is bigger than the lag threshold you have defined in `$wpdb->default_lag_threshold` or in the per-database settings. You can also rewrite the lag threshold, by returning `$server['lag_threshold']` variable with the 'dataset' group callbacks. 205 | 206 | LudicrousDB does not check the lag on the replicas. You have to define two callbacks callbacks to do that: 207 | 208 | ```php 209 | $wpdb->add_callback( $callback, 'get_lag_cache' ); 210 | ``` 211 | 212 | and 213 | 214 | ```php 215 | $wpdb->add_callback( $callback, 'get_lag' ); 216 | ``` 217 | 218 | The first one is called before connecting to a replica, and should return either: the replication lag in seconds, or false if unknown (based on `$wpdb->lag_cache_key`). 219 | 220 | The second callback is called after a connection to a replica is established. It should return either: it's replication lag, or false if unknown (based on the connection in `$wpdb->dbhs[ $wpdb->dbhname ]`). 221 | 222 | ## Sample replication lag detection configuration 223 | 224 | To detect replication lag, try [mk-heartbeat](http://www.maatkit.org/doc/mk-heartbeat.html) or pt-heartbeat from Percona Toolkit. These tools insert a timestamp into a table on the primary and then check the lag on the replicas. The lag is the difference in seconds between the current time and the timestamp on the replica. 225 | 226 | This implementation requires the database user to have read access to the heartbeat table. 227 | 228 | The cache uses shared memory for portability. Can be modified to work with Memcached, APC and etc. 229 | 230 | ```php 231 | $wpdb->lag_cache_ttl = 30; 232 | $wpdb->shmem_key = ftok( __FILE__, "Y" ); 233 | $wpdb->shmem_size = 128 * 1024; 234 | 235 | $wpdb->add_callback( 'get_lag_cache', 'get_lag_cache' ); 236 | $wpdb->add_callback( 'get_lag', 'get_lag' ); 237 | 238 | function get_lag_cache( $wpdb ) { 239 | $segment = shm_attach( $wpdb->shmem_key, $wpdb->shmem_size, 0600 ); 240 | $lag_data = @shm_get_var( $segment, 0 ); 241 | 242 | shm_detach( $segment ); 243 | 244 | if ( ! is_array( $lag_data ) || !is_array( $lag_data[ $wpdb->lag_cache_key ] ) ) { 245 | return false; 246 | } 247 | 248 | if ( $wpdb->lag_cache_ttl < time() - $lag_data[ $wpdb->lag_cache_key ][ 'timestamp' ] ) { 249 | return false; 250 | } 251 | 252 | return $lag_data[ $wpdb->lag_cache_key ][ 'lag' ]; 253 | } 254 | 255 | function get_lag( $wpdb ) { 256 | $dbh = $wpdb->dbhs[ $wpdb->dbhname ]; 257 | 258 | if ( ! mysql_select_db( 'heartbeat', $dbh ) ) { 259 | return false; 260 | } 261 | 262 | $result = mysql_query( "SELECT UNIX_TIMESTAMP() - UNIX_TIMESTAMP(ts) AS lag FROM heartbeat LIMIT 1", $dbh ); 263 | 264 | if ( ! $result || false === $row = mysql_fetch_assoc( $result ) ) { 265 | return false; 266 | } 267 | 268 | // Cache the result in shared memory with timestamp 269 | $sem_id = sem_get( $wpdb->shmem_key, 1, 0600, 1 ); 270 | sem_acquire( $sem_id ); 271 | $segment = shm_attach( $wpdb->shmem_key, $wpdb->shmem_size, 0600 ); 272 | $lag_data = @shm_get_var( $segment, 0 ); 273 | 274 | if ( ! is_array( $lag_data ) ) { 275 | $lag_data = array(); 276 | } 277 | 278 | $lag_data[ $wpdb->lag_cache_key ] = array( 'timestamp' => time(), 'lag' => $row[ 'lag' ] ); 279 | 280 | shm_put_var( $segment, 0, $lag_data ); 281 | shm_detach( $segment ); 282 | sem_release( $sem_id ); 283 | 284 | return $row[ 'lag' ]; 285 | } 286 | ``` 287 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stuttter/ludicrousdb", 3 | "description": "LudicrousDB is a database class that supports replication, failover, load balancing, & partitioning, based on Automattic's HyperDB drop-in.", 4 | "homepage": "https://github.com/stuttter/ludicrousdb", 5 | "type": "wordpress-plugin", 6 | "license" : "GPL-2.0-or-later", 7 | "authors": [ 8 | { 9 | "name": "John James Jacoby", 10 | "email": "johnjamesjacoby@me.com" 11 | }, 12 | { 13 | "name": "Jonny Harris", 14 | "email": "jon@spacedmonkey.co.uk" 15 | } 16 | ], 17 | "support": { 18 | "issues": "https://github.com/stuttter/ludicrousdb/issues", 19 | "source": "https://github.com/stuttter/ludicrousdb" 20 | }, 21 | "require": { 22 | "php": ">=5.2", 23 | "composer/installers": "~1.0 || ~2.0" 24 | }, 25 | "require-dev": { 26 | "wp-coding-standards/wpcs": "^3.1", 27 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0", 28 | "phpcompatibility/phpcompatibility-wp": "^2.1", 29 | "sirbrillig/phpcs-variable-analysis": "^2.8", 30 | "squizlabs/php_codesniffer": "^3.10", 31 | "phpcompatibility/php-compatibility": "^9.3" 32 | }, 33 | "scripts": { 34 | "format": [ 35 | "vendor/bin/phpcbf . -v" 36 | ], 37 | "lint": [ 38 | "vendor/bin/phpcs . -v" 39 | ] 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "composer/installers": true, 44 | "dealerdirect/phpcodesniffer-composer-installer": true 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ludicrousdb.php: -------------------------------------------------------------------------------- 1 | charset = 'utf8mb4'; 23 | 24 | /** 25 | * This sets the default column collation. For best results, investigate which 26 | * collation is recommended for your specific character set. 27 | * 28 | * Default: utf8mb4_unicode_520_ci 29 | */ 30 | $wpdb->collate = 'utf8mb4_unicode_520_ci'; 31 | 32 | /** 33 | * This is useful for debugging. Queries are saved in $wpdb->queries. It is not 34 | * a constant because you might want to use it momentarily. 35 | * Default: false 36 | */ 37 | $wpdb->save_queries = false; 38 | 39 | /** 40 | * The amount of time to wait before trying again to ping mysql server. 41 | * 42 | * Default: 0.1 (Seconds) 43 | */ 44 | $wpdb->recheck_timeout = 0.1; 45 | 46 | /** 47 | * This determines whether to use mysql_connect or mysql_pconnect. The effects 48 | * of this setting may vary and should be carefully tested. 49 | * 50 | * Default: false 51 | */ 52 | $wpdb->persistent = false; 53 | 54 | /** 55 | * This determines whether to use mysql connect or mysql connect has failed and to bail loading the rest of WordPress 56 | * 57 | * Default: false 58 | */ 59 | $wpdb->allow_bail = false; 60 | 61 | /** 62 | * This is the number of mysql connections to keep open. Increase if you expect 63 | * to reuse a lot of connections to different servers. This is ignored if you 64 | * enable persistent connections. 65 | * 66 | * Default: 10 67 | */ 68 | $wpdb->max_connections = 10; 69 | 70 | /** 71 | * Enables checking TCP responsiveness by fsockopen prior to mysql_connect or 72 | * mysql_pconnect. This was added because PHP's mysql functions do not provide 73 | * a variable timeout setting. Disabling it may improve average performance by 74 | * a very tiny margin but lose protection against connections failing slowly. 75 | * 76 | * Default: true 77 | */ 78 | $wpdb->check_tcp_responsiveness = true; 79 | 80 | /** 81 | * The cache group that is used to store TCP responsiveness. 82 | * 83 | * Default: ludicrousdb 84 | */ 85 | $wpdb->cache_group = 'ludicrousdb'; 86 | 87 | /** 88 | * This is the most basic way to add a server to LudicrousDB using only the 89 | * required parameters: host, user, password, name. 90 | * This adds the DB defined in wp-config.php as a read/write server for 91 | * the 'global' dataset. (Every table is in 'global' by default.) 92 | */ 93 | $wpdb->add_database( 94 | array( 95 | 'host' => DB_HOST, // If port is other than 3306, use host:port. 96 | 'user' => DB_USER, 97 | 'password' => DB_PASSWORD, 98 | 'name' => DB_NAME, 99 | ) 100 | ); 101 | 102 | /** 103 | * This adds the same server again, only this time it is configured as a replica. 104 | * The last three parameters are set to the defaults but are shown for clarity. 105 | */ 106 | $wpdb->add_database( 107 | array( 108 | 'host' => DB_HOST, // If port is other than 3306, use host:port. 109 | 'user' => DB_USER, 110 | 'password' => DB_PASSWORD, 111 | 'name' => DB_NAME, 112 | 'write' => 0, 113 | 'read' => 1, 114 | 'dataset' => 'global', 115 | 'timeout' => 0.2, 116 | ) 117 | ); 118 | -------------------------------------------------------------------------------- /ludicrousdb/drop-ins/db-error.php: -------------------------------------------------------------------------------- 1 | 39 | 45 | > 46 | 47 | 48 | <?php esc_html_e( 'Database Error', 'ludicrousdb' ); ?> 49 | 50 | 51 |

52 | 53 | 54 | dbh) of established MySQL connections. 60 | * 61 | * @var array 62 | */ 63 | public $dbhs = array(); 64 | 65 | /** 66 | * Database servers. 67 | * 68 | * Multi-dimensional array (dataset => servers) of datasets and servers. 69 | * 70 | * @var array Default empty array. 71 | */ 72 | public $ludicrous_servers = array(); 73 | 74 | /** 75 | * Database tables. 76 | * 77 | * Optional directory of tables and their datasets. 78 | * 79 | * @var array Default empty array. 80 | */ 81 | public $ludicrous_tables = array(); 82 | 83 | /** 84 | * Callbacks. 85 | * 86 | * Optional directory of callbacks to determine datasets from queries. 87 | * 88 | * @var array Default empty array. 89 | */ 90 | public $ludicrous_callbacks = array(); 91 | 92 | /** 93 | * Custom callback to save debug info in $this->queries. 94 | * 95 | * @var callable Default null. 96 | */ 97 | public $save_query_callback = null; 98 | 99 | /** 100 | * Whether to pass "p:" into mysqli_real_connect() to force a 101 | * persistent connection. 102 | * 103 | * @var bool Default false. 104 | */ 105 | public $persistent = false; 106 | 107 | /** 108 | * Kill the application if a database connection fails. 109 | * 110 | * @var bool Default false. 111 | */ 112 | public $die_on_disconnect = false; 113 | 114 | /** 115 | * The maximum number of db links to keep open. The least-recently used 116 | * link will be closed when the number of links exceeds this. 117 | * 118 | * @var int Default 10. 119 | */ 120 | public $max_connections = 10; 121 | 122 | /** 123 | * Whether to check with fsockopen prior to mysqli_real_connect. 124 | * 125 | * @var bool Default true. 126 | */ 127 | public $check_tcp_responsiveness = true; 128 | 129 | /** 130 | * The amount of time to wait before trying again to ping mysql server. 131 | * 132 | * @var float Default 0.1. 133 | */ 134 | public $recheck_timeout = 0.1; 135 | 136 | /** 137 | * The number of times to retry reconnecting before dying 138 | * 139 | * @var int Default 3. 140 | */ 141 | public $reconnect_retries = 3; 142 | 143 | /** 144 | * The amount of time to wait before trying again to connect to a mysql server. 145 | * 146 | * @var float Default 1. 147 | */ 148 | public $reconnect_sleep = 1.0; 149 | 150 | /** 151 | * Whether to check for heartbeats. 152 | * 153 | * @var bool Default true. 154 | */ 155 | public $check_dbh_heartbeats = true; 156 | 157 | /** 158 | * Keeps track of the dbhname usage and errors. 159 | * 160 | * @var array Default empty array. 161 | */ 162 | public $dbhname_heartbeats = array(); 163 | 164 | /** 165 | * The tables that have been written to. 166 | * 167 | * Disables replica connections if explicitly true. 168 | * 169 | * @var array|bool Default empty array. 170 | */ 171 | public $send_reads_to_primaries = array(); 172 | 173 | /** 174 | * The log of db connections made and the time each one took. 175 | * 176 | * @var array Default empty array. 177 | */ 178 | public $db_connections = array(); 179 | 180 | /** 181 | * The list of unclosed connections sorted by LRU. 182 | * 183 | * @var array Default empty array. 184 | */ 185 | public $open_connections = array(); 186 | 187 | /** 188 | * Lookup array (dbhname => host:port). 189 | * 190 | * @var array Default empty array. 191 | */ 192 | public $dbh2host = array(); 193 | 194 | /** 195 | * The last server used and the database name selected. 196 | * 197 | * @var array Default empty array. 198 | */ 199 | public $last_used_server = array(); 200 | 201 | /** 202 | * Lookup array (dbhname => (server, db name) ) for re-selecting the db 203 | * when a link is re-used. 204 | * 205 | * @var array Default empty array. 206 | */ 207 | public $used_servers = array(); 208 | 209 | /** 210 | * Whether to save debug_backtrace in save_query_callback. You may wish 211 | * to disable this, e.g. when tracing out-of-memory problems. 212 | * 213 | * @var bool Default true. 214 | */ 215 | public $save_backtrace = true; 216 | 217 | /** 218 | * The default database attributes that are used when 219 | * 220 | * @var array Default database values. 221 | */ 222 | public $database_defaults = array( 223 | 'dataset' => 'global', 224 | 'write' => 1, 225 | 'read' => 1, 226 | 'timeout' => 0.2, 227 | 'port' => 3306, 228 | 'lag_threshold' => null, 229 | ); 230 | 231 | /** 232 | * Name of object TCP cache group. 233 | * 234 | * @var string Default 'ludicrousdb'. 235 | */ 236 | public $tcp_cache_group = 'ludicrousdb'; 237 | 238 | /** 239 | * The amount of time to wait before trying again to ping a server. 240 | * 241 | * @var float Default 0.2 seconds (I.E. 200ms). 242 | */ 243 | public $tcp_timeout = 0.2; 244 | 245 | /** 246 | * In memory cache for TCP connected status. 247 | * 248 | * @var array Default empty array. 249 | */ 250 | private $tcp_cache = array(); 251 | 252 | /** 253 | * Whether to ignore replica lag. 254 | * 255 | * @var bool Default false. 256 | */ 257 | private $ignore_replica_lag = false; 258 | 259 | /** 260 | * Number of unique servers. 261 | * 262 | * @var null|int Default null. Might be zero or more. 263 | */ 264 | private $unique_servers = null; 265 | 266 | /** 267 | * Result of the last callback run. 268 | * 269 | * @var mixed Default null. 270 | */ 271 | private $callback_result = null; 272 | 273 | /** 274 | * Array of renamed class variables. 275 | * 276 | * @since 5.2.0 277 | * 278 | * @var array Default key-value array of old and new class variables. 279 | */ 280 | private static $renamed_vars = array( 281 | 'ignore_slave_lag' => 'ignore_replica_lag', 282 | 'srtm' => 'send_reads_to_primaries', 283 | 'allow_bail' => 'die_on_disconnect', 284 | ); 285 | 286 | /** 287 | * Array of binary blob database column types. 288 | * 289 | * @since 5.2.0 290 | * 291 | * @var array Default array of binary blob column types. 292 | */ 293 | private static $bin_blobs = array( 294 | 'BINARY', 295 | 'VARBINARY', 296 | 'TINYBLOB', 297 | 'MEDIUMBLOB', 298 | 'BLOB', 299 | 'LONGBLOB', 300 | ); 301 | 302 | /** 303 | * Array of allowed character sets. 304 | * 305 | * @since 5.2.0 306 | * 307 | * @var array Default array of allowed character sets. 308 | */ 309 | private static $allowed_charsets = array( 310 | 'utf8', 311 | 'utf8mb4', 312 | 'latin1', 313 | ); 314 | 315 | /** 316 | * Gets ready to make database connections 317 | * 318 | * @since 1.0.0 319 | * @since 5.2.0 Matched parameters to parent wpdb class 320 | * 321 | * @param array|string $dbuser New class variables, or Database user. 322 | * @param string $dbpassword Database password. 323 | * @param string $dbname Database name. 324 | * @param string $dbhost Database host. 325 | */ 326 | public function __construct( $dbuser = '', $dbpassword = '', $dbname = '', $dbhost = '' ) { 327 | 328 | // Show errors if debug-display mode is enabled 329 | if ( $this->is_debug_display() ) { 330 | $this->show_errors(); 331 | } 332 | 333 | // Start the TCP cache 334 | $this->tcp_cache_start(); 335 | 336 | // Prepare class vars 337 | $this->prepare_class_vars( $dbuser, $dbpassword, $dbname, $dbhost ); 338 | } 339 | 340 | /** 341 | * Magic method to correctly get renamed attributes. 342 | * 343 | * @since 5.2.0 344 | * 345 | * @param string $name The key to set. 346 | * @return mixed 347 | */ 348 | public function __get( $name ) { 349 | 350 | // Check if old var is in $class_vars_renamed 351 | if ( isset( self::$renamed_vars[ $name ] ) ) { 352 | $name = self::$renamed_vars[ $name ]; 353 | } 354 | 355 | return parent::__get( $name ); 356 | } 357 | 358 | /** 359 | * Magic method to correctly set renamed attributes. 360 | * 361 | * @since 5.2.0 362 | * 363 | * @param string $name The key to set. 364 | * @param mixed $value The value to set. 365 | */ 366 | public function __set( $name, $value ) { 367 | 368 | // Check if old var is in $class_vars_renamed 369 | if ( isset( self::$renamed_vars[ $name ] ) ) { 370 | $name = self::$renamed_vars[ $name ]; 371 | } 372 | 373 | parent::__set( $name, $value ); 374 | } 375 | 376 | /** 377 | * Prepare class vars from constructor. 378 | * 379 | * @since 5.2.0 380 | * 381 | * @param array|string $dbuser New class variables, or Database user. 382 | * @param string $dbpassword Database password. 383 | * @param string $dbname Database name. 384 | * @param string $dbhost Database host. 385 | */ 386 | protected function prepare_class_vars( $dbuser = '', $dbpassword = '', $dbname = '', $dbhost = '' ) { 387 | 388 | // Bail if first method parameter is empty 389 | if ( empty( $dbuser ) ) { 390 | return; 391 | } 392 | 393 | // Default class vars 394 | $class_vars = array(); 395 | 396 | // Custom class vars via array of arguments 397 | if ( is_array( $dbuser ) ) { 398 | $class_vars = $dbuser; 399 | 400 | // WPDB style parameter pattern 401 | } elseif ( is_string( $dbuser ) ) { 402 | 403 | // Only compact if all params are not empty 404 | if ( ! empty( $dbpassword ) && ! empty( $dbname ) && ! empty( $dbhost ) ) { 405 | $class_vars = compact( $dbuser, $dbpassword, $dbname, $dbhost ); 406 | } 407 | } 408 | 409 | // Only set vars if there are vars to set 410 | if ( ! empty( $class_vars ) ) { 411 | $this->set_class_vars( $class_vars ); 412 | } 413 | } 414 | 415 | /** 416 | * Sets class vars from an array of arguments. 417 | * 418 | * @since 5.2.0 419 | * @param array $args Array of variables to set. 420 | */ 421 | protected function set_class_vars( $args = array() ) { 422 | 423 | // Bail if empty arguments 424 | if ( empty( $args ) ) { 425 | return; 426 | } 427 | 428 | // Get class vars as array of keys 429 | $class_vars = get_class_vars( __CLASS__ ); 430 | $class_var_keys = array_keys( $class_vars ); 431 | 432 | /** 433 | * Explicit backwards compatibility for passing default_lag_threshold 434 | * in as a class argument. 435 | * 436 | * @since 5.2.0 437 | */ 438 | if ( 439 | isset( $args['default_lag_threshold'] ) 440 | && 441 | ! isset( $args['database_defaults']['lag_threshold'] ) 442 | ) { 443 | $this->database_defaults['lag_threshold'] = $args['default_lag_threshold']; 444 | } 445 | 446 | // Loop through class vars and override if set in $args 447 | foreach ( $class_var_keys as $var ) { 448 | 449 | // Check if old var is in $args 450 | if ( 451 | isset( self::$renamed_vars[ $var ] ) 452 | && 453 | isset( $args[ self::$renamed_vars[ $var ] ] ) 454 | ) { 455 | $this->{$var} = $args[ self::$renamed_vars[ $var ] ]; 456 | } 457 | 458 | // Check if current var is in $args 459 | if ( isset( $args[ $var ] ) ) { 460 | $this->{$var} = $args[ $var ]; 461 | } 462 | } 463 | } 464 | 465 | /** 466 | * Sets $this->charset and $this->collate 467 | * 468 | * @since 1.0.0 469 | */ 470 | public function init_charset() { 471 | 472 | // Defaults 473 | $charset = 'utf8mb4'; 474 | $collate = 'utf8mb4_unicode_520_ci'; 475 | 476 | // Use constant if defined 477 | if ( defined( 'DB_COLLATE' ) ) { 478 | $collate = DB_COLLATE; 479 | } 480 | 481 | // Use constant if defined 482 | if ( defined( 'DB_CHARSET' ) ) { 483 | $charset = DB_CHARSET; 484 | } 485 | 486 | // Determine charset and collate 487 | $charset_collate = $this->determine_charset( $charset, $collate ); 488 | 489 | // Set charset and collate 490 | $this->charset = $charset_collate['charset']; 491 | $this->collate = $charset_collate['collate']; 492 | } 493 | 494 | /** 495 | * Add the connection parameters for a database 496 | * 497 | * @since 1.0.0 498 | * 499 | * @param array $db Default to empty array. 500 | */ 501 | public function add_database( array $db = array() ) { 502 | 503 | // Merge using defaults 504 | $db = array_merge( $this->database_defaults, $db ); 505 | 506 | // Break these apart to make code easier to understand below 507 | $dataset = $db['dataset']; 508 | $write = $db['write']; 509 | $read = $db['read']; 510 | 511 | // We do not include the dataset in the array. It's used as a key. 512 | unset( $db['dataset'] ); 513 | 514 | // Maybe add database to array of write's 515 | if ( ! empty( $write ) ) { 516 | $this->ludicrous_servers[ $dataset ]['write'][ $write ][] = $db; 517 | } 518 | 519 | // Maybe add database to array of read's 520 | if ( ! empty( $read ) ) { 521 | $this->ludicrous_servers[ $dataset ]['read'][ $read ][] = $db; 522 | } 523 | } 524 | 525 | /** 526 | * Specify the dataset where a table is found 527 | * 528 | * @since 1.0.0 529 | * 530 | * @param string $dataset Database. 531 | * @param string $table Table name. 532 | */ 533 | public function add_table( $dataset, $table ) { 534 | $this->ludicrous_tables[ $table ] = $dataset; 535 | } 536 | 537 | /** 538 | * Add a callback to a group of callbacks 539 | * 540 | * The default group is 'dataset', used to examine queries & determine dataset 541 | * 542 | * @since 1.0.0 543 | * 544 | * @param function $callback Callback on a dataset. 545 | * @param string $group Key name of dataset. 546 | */ 547 | public function add_callback( $callback, $group = 'dataset' ) { 548 | $this->ludicrous_callbacks[ $group ][] = $callback; 549 | } 550 | 551 | /** 552 | * Determine the likelihood that this query could alter anything 553 | * 554 | * Statements are considered read-only when: 555 | * 1. not including UPDATE nor other "may-be-write" strings 556 | * 2. begin with SELECT etc. 557 | * 558 | * @since 1.0.0 559 | * 560 | * @param string $q Query. 561 | * 562 | * @return bool 563 | */ 564 | public function is_write_query( $q = '' ) { 565 | 566 | // Trim potential whitespace or subquery chars 567 | $q = ltrim( $q, "\r\n\t (" ); 568 | 569 | // Possible writes 570 | if ( preg_match( '/(?:^|\s)(?:ALTER|CREATE|ANALYZE|CHECK|OPTIMIZE|REPAIR|CALL|DELETE|DROP|INSERT|LOAD|REPLACE|UPDATE|FOR\s+UPDATE|SET|RENAME\s+TABLE|[a-z]+_LOCKS?\()(?:\s|$)/i', $q ) ) { 571 | return true; 572 | } 573 | 574 | // Not possible non-writes (phew!) 575 | return ! preg_match( '/^(?:SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)(?:\s|$)/i', $q ); 576 | } 577 | 578 | /** 579 | * Is the primary database dead? 580 | * 581 | * @since 5.2.0 582 | * 583 | * @return bool True if primary database is dead, false otherwise. 584 | */ 585 | public function is_primary_dead() { 586 | return ( 587 | defined( 'PRIMARY_DB_DEAD' ) 588 | || 589 | defined( 'MASTER_DB_DEAD' ) 590 | ); 591 | } 592 | 593 | /** 594 | * Is debug mode enabled? 595 | * 596 | * @since 5.2.0 597 | */ 598 | public function is_debug() { 599 | return ( 600 | ( defined( 'LDB_DEBUG' ) && LDB_DEBUG ) 601 | || 602 | ( defined( 'WP_DEBUG' ) && WP_DEBUG ) 603 | ); 604 | } 605 | 606 | /** 607 | * Is debug display mode enabled? 608 | * 609 | * @since 5.2.0 610 | */ 611 | public function is_debug_display() { 612 | return ( 613 | $this->is_debug() 614 | && 615 | ( defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ) 616 | ); 617 | } 618 | 619 | /** 620 | * Are queries being saved? 621 | * 622 | * @since 5.2.0 623 | */ 624 | public function is_saving_queries() { 625 | return ( 626 | ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) 627 | ); 628 | } 629 | 630 | /** 631 | * Set a flag to prevent reading from replicas, which might be lagging 632 | * after a write. 633 | * 634 | * @since 5.2.0 635 | */ 636 | public function send_reads_to_primaries() { 637 | $this->send_reads_to_primaries = true; 638 | } 639 | 640 | /** 641 | * Callbacks are executed in the order in which they are registered until one 642 | * of them returns something other than null 643 | * 644 | * @since 1.0.0 645 | * @param string $group Group, key name in array. 646 | * @param array $args Args passed to callback. Default to null. 647 | */ 648 | public function run_callbacks( $group = '', $args = null ) { 649 | 650 | // Bail if no callbacks for group 651 | if ( 652 | empty( $group ) 653 | || 654 | ! isset( $this->ludicrous_callbacks[ $group ] ) 655 | || 656 | ! is_array( $this->ludicrous_callbacks[ $group ] ) 657 | ) { 658 | return; 659 | } 660 | 661 | // Prepare args 662 | if ( ! isset( $args ) ) { 663 | $args = array( &$this ); 664 | } elseif ( is_array( $args ) ) { 665 | $args[] = &$this; 666 | } else { 667 | $args = array( $args, &$this ); 668 | } 669 | 670 | // Loop through callbacks 671 | foreach ( $this->ludicrous_callbacks[ $group ] as $func ) { 672 | 673 | // Run callback 674 | $result = call_user_func_array( $func, $args ); 675 | 676 | // Return result if not null 677 | if ( isset( $result ) ) { 678 | return $result; 679 | } 680 | } 681 | } 682 | 683 | /** 684 | * Figure out which db server should handle the query, and connect to it. 685 | * 686 | * @since 1.0.0 687 | * 688 | * @param string $query Query. 689 | * 690 | * @return resource MySQL database connection 691 | */ 692 | public function db_connect( $query = '' ) { 693 | 694 | // Bail if empty query 695 | if ( empty( $query ) ) { 696 | return false; 697 | } 698 | 699 | // Fix error reporting change (in PHP 8.1) causing fatal errors 700 | // See: https://php.watch/versions/8.1/mysqli-error-mode 701 | mysqli_report( MYSQLI_REPORT_OFF ); 702 | 703 | // Can be empty/false if the query is e.g. "COMMIT" 704 | $this->table = $this->get_table_from_query( $query ); 705 | if ( empty( $this->table ) ) { 706 | $this->table = 'no-table'; 707 | } 708 | $this->last_table = $this->table; 709 | 710 | // Use current table with no callback results 711 | if ( isset( $this->ludicrous_tables[ $this->table ] ) ) { 712 | $dataset = $this->ludicrous_tables[ $this->table ]; 713 | $this->callback_result = null; 714 | 715 | // Run callbacks and either extract or update dataset 716 | } else { 717 | 718 | // Run callbacks and get result 719 | $this->callback_result = $this->run_callbacks( 'dataset', $query ); 720 | 721 | // Set if not null 722 | if ( ! is_null( $this->callback_result ) ) { 723 | if ( is_array( $this->callback_result ) ) { 724 | extract( $this->callback_result, EXTR_OVERWRITE ); 725 | } else { 726 | $dataset = $this->callback_result; 727 | } 728 | } 729 | } 730 | 731 | if ( ! isset( $dataset ) ) { 732 | $dataset = 'global'; 733 | } 734 | 735 | if ( empty( $dataset ) ) { 736 | return $this->bail( "Unable to determine which dataset to query. ({$this->table})" ); 737 | } else { 738 | $this->dataset = $dataset; 739 | } 740 | 741 | $this->run_callbacks( 'dataset_found', $dataset ); 742 | 743 | if ( empty( $this->ludicrous_servers ) ) { 744 | 745 | // Return early dbh if already set 746 | if ( $this->dbh_type_check( $this->dbh ) ) { 747 | return $this->dbh; 748 | } 749 | 750 | // Bail if missing database constants 751 | if ( 752 | ! defined( 'DB_HOST' ) 753 | || 754 | ! defined( 'DB_USER' ) 755 | || 756 | ! defined( 'DB_PASSWORD' ) 757 | || 758 | ! defined( 'DB_NAME' ) 759 | ) { 760 | return $this->bail( 'We were unable to query because there was no database defined.' ); 761 | } 762 | 763 | // Fallback to wpdb::db_connect() method. 764 | 765 | $this->dbuser = DB_USER; 766 | $this->dbpassword = DB_PASSWORD; 767 | $this->dbname = DB_NAME; 768 | $this->dbhost = DB_HOST; 769 | 770 | parent::db_connect(); 771 | 772 | return $this->dbh; 773 | } 774 | 775 | /** 776 | * Determine whether the query must be sent to the primary (a writable server) 777 | */ 778 | 779 | // Explicitly already set to using the primary 780 | if ( 781 | ! empty( $use_primary ) // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable 782 | || 783 | ( true === $this->send_reads_to_primaries ) 784 | || 785 | isset( $this->send_reads_to_primaries[ $this->table ] ) 786 | ) { 787 | $use_primary = true; 788 | 789 | // Is this a write query? 790 | } elseif ( $this->is_write_query( $query ) ) { 791 | $use_primary = true; 792 | 793 | if ( is_array( $this->send_reads_to_primaries ) ) { 794 | $this->send_reads_to_primaries[ $this->table ] = true; 795 | } 796 | 797 | // Detect queries that have a join in the send_reads_to_primaries array. 798 | } elseif ( 799 | ! isset( $use_primary ) 800 | && 801 | is_array( $this->send_reads_to_primaries ) 802 | && 803 | ! empty( $this->send_reads_to_primaries ) 804 | ) { 805 | $use_primary = false; 806 | $query_match = substr( $query, 0, 1000 ); 807 | 808 | foreach ( $this->send_reads_to_primaries as $key => $value ) { 809 | if ( false !== stripos( $query_match, $key ) ) { 810 | $use_primary = true; 811 | break; 812 | } 813 | } 814 | 815 | // Default to false. 816 | } else { 817 | $use_primary = false; 818 | } 819 | 820 | if ( ! empty( $use_primary ) ) { 821 | $this->dbhname = $dbhname = $dataset . '__w'; 822 | $operation = 'write'; 823 | } else { 824 | $this->dbhname = $dbhname = $dataset . '__r'; 825 | $operation = 'read'; 826 | } 827 | 828 | // Try to reuse an existing connection 829 | while ( 830 | isset( $this->dbhs[ $dbhname ] ) 831 | && 832 | $this->dbh_type_check( $this->dbhs[ $dbhname ] ) 833 | ) { 834 | 835 | // Get the connection indexes 836 | $conns = array_keys( $this->db_connections ); 837 | $conn = 0; 838 | 839 | // Loop through connections to find the matching dbhname 840 | if ( ! empty( $conns ) ) { 841 | foreach ( $conns as $i ) { 842 | if ( $this->db_connections[ $i ]['dbhname'] === $dbhname ) { 843 | $conn = (int) $i; 844 | } 845 | } 846 | } 847 | 848 | // Try to use the database name from the callback, if scalar 849 | if ( 850 | ! empty( $server['name'] ) 851 | && 852 | is_scalar( $server['name'] ) 853 | ) { 854 | $name = (string) $server['name']; 855 | 856 | // A callback specified a database name, but it is possible the 857 | // existing connection selected a different one. 858 | if ( $this->used_servers[ $dbhname ]['name'] !== $name ) { 859 | 860 | // If the select fails, disconnect and try again 861 | if ( ! $this->select( $name, $this->dbhs[ $dbhname ] ) ) { 862 | 863 | // This can happen when the user varies and lacks 864 | // permission on the $name database 865 | $this->increment_db_connection( $conn, 'disconnect (select failed)' ); 866 | $this->disconnect( $dbhname ); 867 | 868 | break; 869 | } 870 | 871 | // Update the used server name 872 | $this->used_servers[ $dbhname ]['name'] = $name; 873 | } 874 | 875 | // Otherwise, use the name from the last connection 876 | } else { 877 | $name = $this->used_servers[ $dbhname ]['name']; 878 | } 879 | 880 | $this->current_host = $this->dbh2host[ $dbhname ]; 881 | 882 | // Keep this connection at the top of the stack to prevent 883 | // disconnecting from frequently-used connections 884 | $key = array_search( $dbhname, $this->open_connections, true ); 885 | if ( $key !== false ) { 886 | unset( $this->open_connections[ $key ] ); 887 | $this->open_connections[] = $dbhname; 888 | } 889 | 890 | $this->last_used_server = $this->used_servers[ $dbhname ]; 891 | $this->last_connection = compact( 'dbhname', 'name' ); 892 | 893 | // Check if the connection is still alive 894 | if ( 895 | $this->should_mysql_ping( $dbhname ) 896 | && 897 | ! $this->check_connection( $this->die_on_disconnect, $this->dbhs[ $dbhname ], $query ) 898 | ) { 899 | $this->increment_db_connection( $conn, 'disconnect (ping failed)' ); 900 | $this->disconnect( $dbhname ); 901 | 902 | break; 903 | } 904 | 905 | // Increment the connection counter 906 | $this->increment_db_connection( $conn, 'queries' ); 907 | 908 | return $this->dbhs[ $dbhname ]; 909 | } 910 | 911 | // Bail if trying to connect to a dead primary 912 | if ( 913 | ! empty( $use_primary ) 914 | && 915 | $this->is_primary_dead() 916 | ) { 917 | return $this->bail( 'We are updating the database. Please try back in 5 minutes. If you are posting to your blog please hit the refresh button on your browser in a few minutes to post the data again. It will be posted as soon as the database is back online.' ); 918 | } 919 | 920 | // Bail if no servers available for table/dataset/operation 921 | if ( empty( $this->ludicrous_servers[ $dataset ][ $operation ] ) ) { 922 | return $this->bail( "No databases available with {$this->table} ({$dataset})" ); 923 | } 924 | 925 | // Put the operations in order by key 926 | ksort( $this->ludicrous_servers[ $dataset ][ $operation ] ); 927 | 928 | // Make a list of at least $this->reconnect_retries connections to try, 929 | // repeating as necessary. 930 | $servers = array(); 931 | do { 932 | foreach ( $this->ludicrous_servers[ $dataset ][ $operation ] as $group => $items ) { 933 | $keys = array_keys( $items ); 934 | 935 | shuffle( $keys ); 936 | 937 | foreach ( $keys as $key ) { 938 | $servers[] = compact( 'group', 'key' ); 939 | } 940 | } 941 | 942 | $tries_remaining = count( $servers ); 943 | if ( 0 === $tries_remaining ) { 944 | return $this->bail( "No database servers were found to match the query. ({$this->table}, {$dataset})" ); 945 | } 946 | 947 | if ( is_null( $this->unique_servers ) ) { 948 | $this->unique_servers = $tries_remaining; 949 | } 950 | } while ( $tries_remaining < $this->reconnect_retries ); 951 | 952 | // Connect to a database server 953 | do { 954 | $unique_lagged_replicas = array(); 955 | $success = false; 956 | 957 | foreach ( $servers as $group_key ) { 958 | --$tries_remaining; 959 | 960 | // If all servers are lagged, we need to start ignoring the lag and retry 961 | if ( count( $unique_lagged_replicas ) === (int) $this->unique_servers ) { 962 | break; 963 | } 964 | 965 | // $group, $key 966 | $group = $group_key['group']; 967 | $key = $group_key['key']; 968 | 969 | // $host, $port, $user, $password, $name, $write, $read, $timeout, $lag_threshold 970 | $db_config = $this->ludicrous_servers[ $dataset ][ $operation ][ $group ][ $key ]; 971 | $host = $db_config['host']; 972 | $port = $db_config['port']; 973 | $user = $db_config['user']; 974 | $password = $db_config['password']; 975 | $name = $db_config['name']; 976 | $write = $db_config['write']; 977 | $read = $db_config['read']; 978 | $timeout = $db_config['timeout']; 979 | $lag_threshold = $db_config['lag_threshold']; 980 | 981 | // Overwrite vars from $server (if it was extracted from a callback) 982 | if ( ! empty( $server ) && is_array( $server ) ) { 983 | extract( $server, EXTR_OVERWRITE ); 984 | 985 | // Otherwise, set $server to an empty array 986 | } else { 987 | $server = array(); 988 | } 989 | 990 | // Maybe split host:port into $host and $port 991 | if ( strpos( $host, ':' ) ) { 992 | list( $host, $port ) = explode( ':', $host ); 993 | } 994 | 995 | // Maybe use the default port number (usually: 3306) 996 | if ( empty( $port ) ) { 997 | $port = (int) $this->database_defaults['port']; 998 | } 999 | 1000 | // Maybe use the default timeout (usually: 200ms) 1001 | if ( ! isset( $timeout ) ) { 1002 | $timeout = (float) $this->tcp_timeout; 1003 | } 1004 | 1005 | // Get the minimum group here, in case $server rewrites it 1006 | if ( ! isset( $min_group ) || ( $min_group > $group ) ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable 1007 | $min_group = $group; 1008 | } 1009 | 1010 | // Format the cache key using the extracted host and port 1011 | $host_and_port = $this->tcp_get_cache_key( $host, $port ); 1012 | 1013 | // Can be used by the lag callbacks 1014 | $this->lag_cache_key = $host_and_port; 1015 | $this->lag_threshold = isset( $lag_threshold ) 1016 | ? $lag_threshold 1017 | : $this->database_defaults['lag_threshold']; 1018 | 1019 | // Check for a lagged replica, if applicable 1020 | if ( 1021 | empty( $use_primary ) 1022 | && 1023 | empty( $write ) 1024 | && 1025 | empty( $this->ignore_replica_lag ) 1026 | && 1027 | isset( $this->lag_threshold ) 1028 | && 1029 | ! isset( $server['host'] ) 1030 | && 1031 | ( $lagged_status = $this->get_lag_cache() ) === DB_LAG_BEHIND // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition 1032 | ) { 1033 | 1034 | // If it is the last lagged replica. and it is with the best 1035 | // preference, we will ignore its lag 1036 | if ( 1037 | ! isset( $unique_lagged_replicas[ $host_and_port ] ) 1038 | && 1039 | ( ( count( $unique_lagged_replicas ) + 1 ) === (int) $this->unique_servers ) 1040 | && 1041 | ( $group === $min_group ) 1042 | ) { 1043 | $this->lag_threshold = null; 1044 | 1045 | // Otherwise, log the lag and continue on 1046 | } else { 1047 | $unique_lagged_replicas[ $host_and_port ] = $this->lag; 1048 | 1049 | continue; 1050 | } 1051 | } 1052 | 1053 | $this->timer_start(); 1054 | 1055 | // Maybe check TCP responsiveness 1056 | $tcp = ! empty( $this->check_tcp_responsiveness ) 1057 | ? $this->check_tcp_responsiveness( $host, $port, $timeout ) 1058 | : null; 1059 | 1060 | // Connect if necessary or possible 1061 | if ( 1062 | ! empty( $use_primary ) 1063 | || 1064 | empty( $tries_remaining ) 1065 | || 1066 | ( true === $tcp ) 1067 | ) { 1068 | $this->single_db_connect( $dbhname, $host_and_port, $user, $password ); 1069 | } else { 1070 | $this->dbhs[ $dbhname ] = false; 1071 | } 1072 | 1073 | $elapsed = $this->timer_stop(); 1074 | 1075 | if ( $this->dbh_type_check( $this->dbhs[ $dbhname ] ) ) { 1076 | /** 1077 | * If we care about lag, disconnect lagged replicas and try 1078 | * to find others. We don't disconnect if it is the last 1079 | * lagged replica and it is with the best preference. 1080 | */ 1081 | if ( 1082 | empty( $use_primary ) 1083 | && 1084 | empty( $write ) 1085 | && 1086 | empty( $this->ignore_replica_lag ) 1087 | && 1088 | isset( $this->lag_threshold ) 1089 | && 1090 | ! isset( $server['host'] ) 1091 | && 1092 | ( $lagged_status !== DB_LAG_OK ) 1093 | && 1094 | ( $lagged_status = $this->get_lag() ) === DB_LAG_BEHIND // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition 1095 | && 1096 | ! ( 1097 | ! isset( $unique_lagged_replicas[ $host_and_port ] ) 1098 | && 1099 | ( (int) $this->unique_servers === ( count( $unique_lagged_replicas ) + 1 ) ) 1100 | && 1101 | ( $group === $min_group ) 1102 | ) 1103 | ) { 1104 | $unique_lagged_replicas[ $host_and_port ] = $this->lag; 1105 | $this->disconnect( $dbhname ); 1106 | 1107 | $this->dbhs[ $dbhname ] = false; 1108 | $success = false; 1109 | $msg = "Replication lag of {$this->lag}s on {$host_and_port} ({$dbhname})"; 1110 | 1111 | $this->print_error( $msg ); 1112 | 1113 | continue; 1114 | 1115 | } else { 1116 | $this->set_sql_mode( array(), $this->dbhs[ $dbhname ] ); 1117 | 1118 | if ( $this->select( $name, $this->dbhs[ $dbhname ] ) ) { 1119 | $this->current_host = $host_and_port; 1120 | $this->dbh2host[ $dbhname ] = $host_and_port; 1121 | 1122 | // Define these to avoid undefined variable notices 1123 | $queries = isset( $queries ) ? $queries : 1; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable 1124 | $lag = isset( $this->lag ) ? $this->lag : 0; 1125 | 1126 | $this->last_connection = compact( 'dbhname', 'host', 'port', 'user', 'name', 'tcp', 'elapsed', 'success', 'queries', 'lag' ); 1127 | $this->db_connections[] = $this->last_connection; 1128 | $this->open_connections[] = $dbhname; 1129 | $success = true; 1130 | 1131 | break; 1132 | } 1133 | } 1134 | } 1135 | 1136 | $success = false; 1137 | $this->last_connection = compact( 'dbhname', 'host', 'port', 'user', 'name', 'tcp', 'elapsed', 'success' ); 1138 | $this->db_connections[] = $this->last_connection; 1139 | 1140 | if ( $this->dbh_type_check( $this->dbhs[ $dbhname ] ) ) { 1141 | $error = mysqli_error( $this->dbhs[ $dbhname ] ); 1142 | $errno = mysqli_errno( $this->dbhs[ $dbhname ] ); 1143 | } 1144 | 1145 | $msg = date( 'Y-m-d H:i:s' ) . " Can't select {$dbhname} - \n"; // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date 1146 | $msg .= "'referrer' => '{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}',\n"; 1147 | $msg .= "'host' => {$host},\n"; 1148 | 1149 | if ( ! empty( $error ) ) { 1150 | $msg .= "'error' => {$error},\n"; 1151 | } 1152 | 1153 | if ( ! empty( $errno ) ) { 1154 | $msg .= "'errno' => {$errno},\n"; 1155 | 1156 | // Maybe log the error to heartbeats 1157 | if ( ! empty( $this->check_dbh_heartbeats ) ) { 1158 | $this->dbhname_heartbeats[ $dbhname ]['last_errno'] = $errno; 1159 | } 1160 | } 1161 | 1162 | $msg .= "'tcp_responsive' => " . ( $tcp === true 1163 | ? 'true' 1164 | : $tcp ) . ",\n"; 1165 | 1166 | $msg .= "'lagged_status' => " . ( isset( $lagged_status ) 1167 | ? $lagged_status 1168 | : DB_LAG_UNKNOWN ); 1169 | 1170 | $this->print_error( $msg ); 1171 | } 1172 | 1173 | // Maybe bail if we have tried all the servers and none of them 1174 | // worked. 1175 | if ( 1176 | empty( $success ) 1177 | || 1178 | ! isset( $this->dbhs[ $dbhname ] ) 1179 | || 1180 | ! $this->dbh_type_check( $this->dbhs[ $dbhname ] ) 1181 | ) { 1182 | 1183 | // Lagged replicas were not used. Ignore the lag for this 1184 | // connection attempt and retry. 1185 | if ( 1186 | empty( $this->ignore_replica_lag ) 1187 | && 1188 | count( $unique_lagged_replicas ) 1189 | ) { 1190 | $this->ignore_replica_lag = true; 1191 | $tries_remaining = count( $servers ); 1192 | 1193 | continue; 1194 | } 1195 | 1196 | // Setup the callback data 1197 | $callback_data = array( 1198 | 'host' => $host, 1199 | 'port' => $port, 1200 | 'operation' => $operation, 1201 | 'table' => $this->table, 1202 | 'dataset' => $dataset, 1203 | 'dbhname' => $dbhname, 1204 | ); 1205 | 1206 | $this->run_callbacks( 'db_connection_error', $callback_data ); 1207 | 1208 | return $this->bail( "Unable to connect to {$host}:{$port} to {$operation} table '{$this->table}' ({$dataset})" ); 1209 | } 1210 | 1211 | break; 1212 | } while ( true ); 1213 | 1214 | $this->set_charset( $this->dbhs[ $dbhname ] ); 1215 | 1216 | $this->dbh = $this->dbhs[ $dbhname ]; // needed by $wpdb->_real_escape() 1217 | $this->last_used_server = compact( 'host', 'user', 'name', 'write', 'read' ); 1218 | $this->used_servers[ $dbhname ] = $this->last_used_server; 1219 | 1220 | while ( 1221 | ( false === $this->persistent ) 1222 | && 1223 | ( count( $this->open_connections ) > $this->max_connections ) // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found 1224 | ) { 1225 | $oldest_connection = array_shift( $this->open_connections ); 1226 | 1227 | if ( $this->dbhs[ $oldest_connection ] !== $this->dbhs[ $dbhname ] ) { 1228 | $this->disconnect( $oldest_connection ); 1229 | } 1230 | } 1231 | 1232 | return $this->dbhs[ $dbhname ]; 1233 | } 1234 | 1235 | /** 1236 | * Increment a database connection counter. 1237 | * 1238 | * @since 5.2.0 1239 | * @param int $connection Connection index. 1240 | * @param string $name Connection name. 1241 | */ 1242 | protected function increment_db_connection( $connection = 0, $name = '' ) { 1243 | 1244 | // Bail if name is empty 1245 | if ( empty( $name ) ) { 1246 | return; 1247 | } 1248 | 1249 | // Initialize the connection counter 1250 | if ( ! isset( $this->db_connections[ $connection ] ) ) { 1251 | $this->db_connections[ $connection ] = array(); 1252 | } 1253 | 1254 | // Increment the connection counter 1255 | if ( ! isset( $this->db_connections[ $connection ][ $name ] ) ) { 1256 | $this->db_connections[ $connection ][ $name ] = 1; 1257 | } else { 1258 | ++$this->db_connections[ $connection ][ $name ]; 1259 | } 1260 | } 1261 | 1262 | /** 1263 | * Connect selected database 1264 | * 1265 | * @since 1.0.0 1266 | * 1267 | * @param string $dbhname Database name. 1268 | * @param string $host Internet address: host:port of server on internet. 1269 | * @param string $user Database user. 1270 | * @param string $password Database password. 1271 | * 1272 | * @return bool|mysqli|resource 1273 | */ 1274 | protected function single_db_connect( $dbhname, $host, $user, $password ) { 1275 | $this->is_mysql = true; 1276 | 1277 | // Check client flags 1278 | $client_flags = defined( 'MYSQL_CLIENT_FLAGS' ) 1279 | ? MYSQL_CLIENT_FLAGS 1280 | : 0; 1281 | 1282 | // Initialize the database handle 1283 | $this->dbhs[ $dbhname ] = mysqli_init(); 1284 | 1285 | /** 1286 | * mysqli_real_connect doesn't support the "host" param including a port 1287 | * or socket like mysql_connect does. This duplicates how mysql_connect 1288 | * detects a port and/or socket file. 1289 | */ 1290 | $port = 0; 1291 | $socket = ''; 1292 | $port_or_socket = strstr( $host, ':' ); 1293 | 1294 | if ( ! empty( $port_or_socket ) ) { 1295 | $host = substr( $host, 0, strpos( $host, ':' ) ); 1296 | $port_or_socket = substr( $port_or_socket, 1 ); 1297 | 1298 | if ( 0 !== strpos( $port_or_socket, '/' ) ) { 1299 | $port = intval( $port_or_socket ); 1300 | $maybe_socket = strstr( $port_or_socket, ':' ); 1301 | 1302 | if ( ! empty( $maybe_socket ) ) { 1303 | $socket = substr( $maybe_socket, 1 ); 1304 | } 1305 | } else { 1306 | $socket = $port_or_socket; 1307 | } 1308 | } 1309 | 1310 | /** 1311 | * If DB_HOST begins with a 'p:', allow it to be passed to 1312 | * mysqli_real_connect(). mysqli supports persistent connections 1313 | * starting with PHP 5.3.0. 1314 | */ 1315 | if ( 1316 | ( true === $this->persistent ) 1317 | && 1318 | version_compare( phpversion(), '5.3.0', '>=' ) 1319 | ) { 1320 | $pre_host = 'p:'; 1321 | } else { 1322 | $pre_host = ''; 1323 | } 1324 | 1325 | // Connect to the database 1326 | mysqli_real_connect( 1327 | $this->dbhs[ $dbhname ], 1328 | $pre_host . $host, 1329 | $user, 1330 | $password, 1331 | '', 1332 | $port, 1333 | $socket, 1334 | $client_flags 1335 | ); 1336 | 1337 | // Bail if connection failed 1338 | if ( ! empty( $this->dbhs[ $dbhname ]->connect_errno ) ) { 1339 | $this->dbhs[ $dbhname ] = false; 1340 | 1341 | return false; 1342 | } 1343 | } 1344 | 1345 | /** 1346 | * Change the current SQL mode, and ensure its WordPress compatibility. 1347 | * 1348 | * If no modes are passed, it will ensure the current MySQL server 1349 | * modes are compatible. 1350 | * 1351 | * @since 1.0.0 1352 | * 1353 | * @param array $modes Optional. A list of SQL modes to set. 1354 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 1355 | * - the current database 1356 | * - the database housing the specified table 1357 | * - the database of the MySQL resource 1358 | * @return void 1359 | */ 1360 | public function set_sql_mode( $modes = array(), $dbh_or_table = false ) { 1361 | $dbh = $this->get_db_object( $dbh_or_table ); 1362 | 1363 | if ( ! $this->dbh_type_check( $dbh ) ) { 1364 | return; 1365 | } 1366 | 1367 | if ( empty( $modes ) ) { 1368 | $res = mysqli_query( $dbh, 'SELECT @@SESSION.sql_mode' ); 1369 | 1370 | if ( empty( $res ) ) { 1371 | return; 1372 | } 1373 | 1374 | $modes_array = mysqli_fetch_array( $res ); 1375 | if ( empty( $modes_array[0] ) ) { 1376 | return; 1377 | } 1378 | 1379 | $modes_str = $modes_array[0]; 1380 | 1381 | if ( empty( $modes_str ) ) { 1382 | return; 1383 | } 1384 | 1385 | $modes = explode( ',', $modes_str ); 1386 | } 1387 | 1388 | $modes = array_change_key_case( $modes, CASE_UPPER ); 1389 | 1390 | /** 1391 | * Filter the list of incompatible SQL modes to exclude. 1392 | * 1393 | * @param array $incompatible_modes An array of incompatible modes. 1394 | */ 1395 | $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $this->incompatible_modes ); 1396 | foreach ( $modes as $i => $mode ) { 1397 | if ( in_array( $mode, $incompatible_modes, true ) ) { 1398 | unset( $modes[ $i ] ); 1399 | } 1400 | } 1401 | 1402 | $modes_str = implode( ',', $modes ); 1403 | 1404 | mysqli_query( $dbh, "SET SESSION sql_mode='{$modes_str}'" ); 1405 | } 1406 | 1407 | /** 1408 | * Selects a database using the current database connection. 1409 | * 1410 | * The database name will be changed based on the current database 1411 | * connection. On failure, the execution will bail and display an DB error. 1412 | * 1413 | * @since 1.0.0 1414 | * 1415 | * @param string $db MySQL database name. 1416 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 1417 | * - the current database 1418 | * - the database housing the specified table 1419 | * - the database of the MySQL resource 1420 | */ 1421 | public function select( $db, $dbh_or_table = false ) { 1422 | $dbh = $this->get_db_object( $dbh_or_table ); 1423 | 1424 | if ( ! $this->dbh_type_check( $dbh ) ) { 1425 | return false; 1426 | } 1427 | 1428 | $success = mysqli_select_db( $dbh, $db ); 1429 | 1430 | return $success; 1431 | } 1432 | 1433 | /** 1434 | * Load the column metadata from the last query. 1435 | * 1436 | * @since 1.0.0 1437 | * 1438 | * @access protected 1439 | */ 1440 | protected function load_col_info() { 1441 | 1442 | // Bail if not enough info 1443 | if ( 1444 | ! empty( $this->col_info ) 1445 | || 1446 | ( false === $this->result ) 1447 | ) { 1448 | return; 1449 | } 1450 | 1451 | $this->col_info = array(); 1452 | 1453 | $num_fields = mysqli_num_fields( $this->result ); 1454 | 1455 | for ( $i = 0; $i < $num_fields; $i++ ) { 1456 | $this->col_info[ $i ] = mysqli_fetch_field( $this->result ); 1457 | } 1458 | } 1459 | 1460 | /** 1461 | * Force addslashes() for the escapes 1462 | * 1463 | * LudicrousDB makes connections when a query is made which is why we can't 1464 | * use mysql_real_escape_string() for escapes 1465 | * 1466 | * This is also the reason why we don't allow certain charsets. 1467 | * 1468 | * See set_charset(). 1469 | * 1470 | * @since 1.0.0 1471 | * @param string $to_escape String to escape. 1472 | */ 1473 | public function _real_escape( $to_escape = '' ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore 1474 | 1475 | // Bail if not a scalar 1476 | if ( ! is_scalar( $to_escape ) ) { 1477 | return ''; 1478 | } 1479 | 1480 | // Slash the query part 1481 | $escaped = addslashes( $to_escape ); 1482 | 1483 | // Maybe use WordPress core placeholder method 1484 | if ( method_exists( $this, 'add_placeholder_escape' ) ) { 1485 | $escaped = $this->add_placeholder_escape( $escaped ); 1486 | } 1487 | 1488 | return $escaped; 1489 | } 1490 | 1491 | /** 1492 | * Sets the connection's character set 1493 | * 1494 | * @since 1.0.0 1495 | * 1496 | * @param mysqli|resource $dbh The resource given by mysqli_real_connect 1497 | * @param string $charset Optional. The character set. 1498 | * @param string $collate Optional. The collation. 1499 | */ 1500 | public function set_charset( $dbh, $charset = null, $collate = null ) { 1501 | 1502 | // Default charset 1503 | if ( ! isset( $charset ) ) { 1504 | $charset = $this->charset; 1505 | } 1506 | 1507 | // Default collation 1508 | if ( ! isset( $collate ) ) { 1509 | $collate = $this->collate; 1510 | } 1511 | 1512 | // Exit if charset or collation are empty 1513 | if ( empty( $charset ) || empty( $collate ) ) { 1514 | wp_die( "{$charset} {$collate}" ); 1515 | } 1516 | 1517 | // Exit if charset is not allowed 1518 | if ( ! in_array( strtolower( $charset ), self::$allowed_charsets, true ) ) { 1519 | wp_die( "{$charset} charset isn't supported in LudicrousDB for security reasons" ); 1520 | } 1521 | 1522 | // Bail if cannot set collation 1523 | if ( ! $this->has_cap( 'collation', $dbh ) ) { 1524 | return; 1525 | } 1526 | 1527 | // Attempt to set the character set 1528 | $do_set_names_query = $this->has_cap( 'set_charset', $dbh ) 1529 | ? mysqli_set_charset( $dbh, $charset ) 1530 | : true; 1531 | 1532 | // Bail if client charset could not be set 1533 | if ( false === $do_set_names_query ) { 1534 | return; 1535 | } 1536 | 1537 | // Start the query with charset 1538 | $query = $this->prepare( 'SET NAMES %s', $charset ); 1539 | 1540 | // Maybe add collation to query 1541 | if ( ! empty( $collate ) ) { 1542 | $query .= $this->prepare( ' COLLATE %s', $collate ); 1543 | } 1544 | 1545 | // Do the query 1546 | $this->_do_query( $query, $dbh ); 1547 | } 1548 | 1549 | /** 1550 | * Disconnect and remove connection from open connections list 1551 | * 1552 | * @since 1.0.0 1553 | * 1554 | * @param string $dbhname Database name. 1555 | */ 1556 | public function disconnect( $dbhname ) { 1557 | $key = array_search( $dbhname, $this->open_connections, true ); 1558 | 1559 | if ( $key !== false ) { 1560 | unset( $this->open_connections[ $key ] ); 1561 | } 1562 | 1563 | if ( $this->dbh_type_check( $this->dbhs[ $dbhname ] ) ) { 1564 | $this->close( $this->dbhs[ $dbhname ] ); 1565 | } 1566 | 1567 | unset( $this->dbhs[ $dbhname ] ); 1568 | } 1569 | 1570 | /** 1571 | * Kill cached query results 1572 | * 1573 | * @since 1.0.0 1574 | */ 1575 | public function flush() { 1576 | $this->last_error = ''; 1577 | $this->num_rows = 0; 1578 | parent::flush(); 1579 | } 1580 | 1581 | /** 1582 | * Check that the connection to the database is still up. If not, try 1583 | * to reconnect. 1584 | * 1585 | * This function is called internally by LudicrousDB when a database 1586 | * connection 1587 | * 1588 | * If this function is unable to reconnect, it will forcibly die, or if 1589 | * after the "template_redirect" hook has been fired, return false instead. 1590 | * 1591 | * If $die_on_disconnect is false, the lack of database connection will need 1592 | * to be handled manually. 1593 | * 1594 | * @since 1.0.0 1595 | * 1596 | * @param bool $die_on_disconnect Optional. Allows the function to die. Default true. 1597 | * @param bool $dbh_or_table Optional. 1598 | * @param string $query Optional. Query string passed db_connect 1599 | * 1600 | * @return bool|void True if the connection is up. 1601 | */ 1602 | public function check_connection( $die_on_disconnect = true, $dbh_or_table = false, $query = '' ) { 1603 | $dbh = $this->get_db_object( $dbh_or_table ); 1604 | 1605 | // Return true if ping is successful. This is the most common case. 1606 | if ( 1607 | $this->dbh_type_check( $dbh ) 1608 | && 1609 | mysqli_ping( $dbh ) 1610 | ) { 1611 | return true; 1612 | } 1613 | 1614 | // Default to false 1615 | $error_reporting = false; 1616 | 1617 | // Disable warnings, as we don't want to see a multitude of "unable to connect" messages 1618 | if ( $this->is_debug() ) { 1619 | $error_reporting = error_reporting(); 1620 | error_reporting( $error_reporting & ~E_WARNING ); 1621 | } 1622 | 1623 | // Ping failed, so try to reconnect manually 1624 | for ( $tries = 1; $tries <= $this->reconnect_retries; $tries++ ) { 1625 | 1626 | // Try to reconnect 1627 | $retry = $this->db_connect( $query ); 1628 | 1629 | // Return true if the connection is up 1630 | if ( false !== $retry ) { 1631 | return true; 1632 | 1633 | // On the last try, re-enable warnings. We want to see a single 1634 | // instance of the "unable to connect" message on the bail() 1635 | // screen, if it appears. 1636 | } elseif ( 1637 | ( $this->reconnect_retries === $tries ) 1638 | && 1639 | $this->is_debug() 1640 | ) { 1641 | error_reporting( $error_reporting ); 1642 | } 1643 | 1644 | // Sleep before retrying 1645 | sleep( $this->reconnect_sleep ); 1646 | } 1647 | 1648 | // Bail here if not allowed to call $this->bail() 1649 | if ( false === $die_on_disconnect ) { 1650 | return false; 1651 | } 1652 | 1653 | // Bail if template_redirect has already happened, because it's too 1654 | // late for wp_die()/dead_db() 1655 | if ( did_action( 'template_redirect' ) ) { 1656 | return false; 1657 | } 1658 | 1659 | // Load translations early so that the error message can be translated 1660 | wp_load_translations_early(); 1661 | 1662 | $message = '

' . __( 'Error reconnecting to the database', 'ludicrousdb' ) . "

\n"; 1663 | $message .= '

' . sprintf( 1664 | /* translators: %s: database host */ 1665 | __( 'This means that we lost contact with the database server at %s. This could mean your host’s database server is down.', 'ludicrousdb' ), 1666 | '' . htmlspecialchars( $this->dbhost, ENT_QUOTES ) . '' 1667 | ) . "

\n"; 1668 | $message .= "\n"; 1672 | $message .= '

' . sprintf( 1673 | /* translators: %s: support forums URL */ 1674 | __( 'If you’re unsure what these terms mean you should probably contact your host. If you still need help you can always visit the WordPress Support Forums.', 'ludicrousdb' ), 1675 | __( 'https://wordpress.org/support/', 'ludicrousdb' ) 1676 | ) . "

\n"; 1677 | 1678 | // We weren't able to reconnect, so we better bail. 1679 | $this->bail( $message, 'db_connect_fail' ); 1680 | 1681 | // Call dead_db() if bail didn't die, because this database is no more. 1682 | // It has ceased to be (at least temporarily). 1683 | dead_db(); 1684 | } 1685 | 1686 | /** 1687 | * Basic query. See documentation for more details. 1688 | * 1689 | * @since 1.0.0 1690 | * @since 5.2.0 Added support for SELECT modifiers (e.g. DISTINCT, HIGH_PRIORITY, etc...) 1691 | * 1692 | * @param string $query Query. 1693 | * 1694 | * @return int number of rows 1695 | */ 1696 | public function query( $query ) { 1697 | 1698 | // Default return value 1699 | $retval = 0; 1700 | 1701 | $this->flush(); 1702 | 1703 | // Some queries are made before plugins are loaded 1704 | if ( function_exists( 'apply_filters' ) ) { 1705 | 1706 | /** 1707 | * Filter the database query. 1708 | * 1709 | * Some queries are made before the plugins have been loaded, 1710 | * and thus cannot be filtered with this method. 1711 | * 1712 | * @param string $query Database query. 1713 | */ 1714 | $query = apply_filters( 'query', $query ); 1715 | } 1716 | 1717 | // Some queries are made before plugins are loaded 1718 | if ( function_exists( 'apply_filters_ref_array' ) ) { 1719 | 1720 | /** 1721 | * Filter the return value before the query is run. 1722 | * 1723 | * Passing a non-null value to the filter will effectively short-circuit 1724 | * the DB query, stopping it from running, then returning this value instead. 1725 | * 1726 | * This uses apply_filters_ref_array() to allow $this to be manipulated, so 1727 | * values can be set just-in-time to match your particular use-case. 1728 | * 1729 | * You probably will never need to use this filter, but if you do, there's 1730 | * no other way to do what you're trying to do, so here you go! 1731 | * 1732 | * @since 4.0.0 1733 | * 1734 | * @param string null The filtered return value. Default is null. 1735 | * @param string $query Database query. 1736 | * @param LudicrousDB &$this Current instance of LudicrousDB, passed by reference. 1737 | */ 1738 | $retval = apply_filters_ref_array( 'pre_query', array( null, $query, &$this ) ); 1739 | if ( null !== $retval ) { 1740 | $this->run_query_log_callbacks( $query, $retval ); 1741 | 1742 | return $retval; 1743 | } 1744 | } 1745 | 1746 | // Bail if query is empty (via application error or 'query' filter) 1747 | if ( empty( $query ) ) { 1748 | $this->run_query_log_callbacks( $query, $retval ); 1749 | 1750 | return $retval; 1751 | } 1752 | 1753 | // Log how the function was called 1754 | $this->func_call = "\$db->query(\"{$query}\")"; 1755 | 1756 | // If we're writing to the database, make sure the query will write safely. 1757 | if ( 1758 | $this->check_current_query 1759 | && 1760 | ! $this->check_ascii( $query ) 1761 | ) { 1762 | $stripped_query = $this->strip_invalid_text_from_query( $query ); 1763 | 1764 | // strip_invalid_text_from_query() may perform queries, so 1765 | // flush again to make sure everything is clear. 1766 | $this->flush(); 1767 | 1768 | if ( $stripped_query !== $query ) { 1769 | $this->insert_id = 0; 1770 | $retval = false; 1771 | 1772 | $this->run_query_log_callbacks( $query, $retval ); 1773 | 1774 | return $retval; 1775 | } 1776 | } 1777 | 1778 | $this->check_current_query = true; 1779 | 1780 | // Keep track of the last query for debug.. 1781 | $this->last_query = $query; 1782 | 1783 | if ( 1784 | preg_match( '/^\s*SELECT\s+FOUND_ROWS(\s*)/i', $query ) 1785 | && 1786 | ( $this->last_found_rows_result instanceof mysqli_result ) 1787 | ) { 1788 | $this->result = $this->last_found_rows_result; 1789 | $elapsed = 0; 1790 | 1791 | } else { 1792 | $this->dbh = $this->db_connect( $query ); 1793 | 1794 | if ( ! $this->dbh_type_check( $this->dbh ) ) { 1795 | $this->run_query_log_callbacks( $query, $retval ); 1796 | 1797 | return false; 1798 | } 1799 | 1800 | $this->timer_start(); 1801 | $this->result = $this->_do_query( $query, $this->dbh ); 1802 | $elapsed = $this->timer_stop(); 1803 | 1804 | ++$this->num_queries; 1805 | 1806 | if ( preg_match( '/^\s*SELECT\s+([A-Z_]+\s+)*SQL_CALC_FOUND_ROWS\s/i', $query ) ) { 1807 | if ( false === strpos( $query, 'NO_SELECT_FOUND_ROWS' ) ) { 1808 | $this->timer_start(); 1809 | $this->last_found_rows_result = $this->_do_query( 'SELECT FOUND_ROWS()', $this->dbh ); 1810 | $elapsed += $this->timer_stop(); 1811 | ++$this->num_queries; 1812 | $query .= '; SELECT FOUND_ROWS()'; 1813 | } 1814 | } else { 1815 | $this->last_found_rows_result = null; 1816 | } 1817 | 1818 | if ( 1819 | ! empty( $this->save_queries ) 1820 | || 1821 | $this->is_saving_queries() 1822 | ) { 1823 | $this->log_query( 1824 | $query, 1825 | $elapsed, 1826 | $this->get_caller(), 1827 | $this->time_start, 1828 | array() 1829 | ); 1830 | } 1831 | } 1832 | 1833 | // If there is an error then take note of it 1834 | if ( $this->dbh_type_check( $this->dbh ) ) { 1835 | $this->last_error = mysqli_error( $this->dbh ); 1836 | } 1837 | 1838 | if ( ! empty( $this->last_error ) ) { 1839 | $this->print_error( $this->last_error ); 1840 | $retval = false; 1841 | 1842 | $this->run_query_log_callbacks( $query, $retval ); 1843 | 1844 | return $retval; 1845 | } 1846 | 1847 | if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) { 1848 | $retval = $this->result; 1849 | 1850 | } elseif ( preg_match( '/^\\s*(insert|delete|update|replace|alter) /i', $query ) ) { 1851 | $this->rows_affected = mysqli_affected_rows( $this->dbh ); 1852 | 1853 | // Take note of the insert_id 1854 | if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) { 1855 | $this->insert_id = mysqli_insert_id( $this->dbh ); 1856 | } 1857 | 1858 | // Return number of rows affected 1859 | $retval = $this->rows_affected; 1860 | 1861 | } else { 1862 | $num_rows = 0; 1863 | $this->last_result = array(); 1864 | 1865 | if ( $this->result instanceof mysqli_result ) { 1866 | $this->load_col_info(); 1867 | 1868 | while ( $row = mysqli_fetch_object( $this->result ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition 1869 | $this->last_result[ $num_rows ] = $row; 1870 | 1871 | // phpcs:ignore 1872 | $num_rows++; 1873 | } 1874 | } 1875 | 1876 | // Log number of rows the query returned 1877 | // and return number of rows selected 1878 | $this->num_rows = $num_rows; 1879 | $retval = $num_rows; 1880 | } 1881 | 1882 | $this->run_query_log_callbacks( $query, $retval ); 1883 | 1884 | // Some queries are made before plugins are loaded 1885 | if ( function_exists( 'do_action_ref_array' ) ) { 1886 | 1887 | /** 1888 | * Runs after a query is finished. 1889 | * 1890 | * @since 4.0.0 1891 | * 1892 | * @param string $query Database query. 1893 | * @param LudicrousDB &$this Current instance of LudicrousDB, passed by reference. 1894 | */ 1895 | do_action_ref_array( 'queried', array( $query, &$this ) ); 1896 | } 1897 | 1898 | // Return number of rows 1899 | return $retval; 1900 | } 1901 | 1902 | /** 1903 | * Internal function to perform the mysqli_query() call 1904 | * 1905 | * @since 1.0.0 1906 | * 1907 | * @access protected 1908 | * @see wpdb::query() 1909 | * 1910 | * @param string $query The query to run. 1911 | * @param bool $dbh_or_table Database or table name. Defaults to false. 1912 | * @throws Throwable If the query fails. 1913 | */ 1914 | protected function _do_query( $query, $dbh_or_table = false ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore 1915 | $dbh = $this->get_db_object( $dbh_or_table ); 1916 | 1917 | if ( ! $this->dbh_type_check( $dbh ) ) { 1918 | return false; 1919 | } 1920 | 1921 | // Try to execute the query 1922 | try { 1923 | $result = mysqli_query( $dbh, $query ); 1924 | 1925 | // Catch any exceptions 1926 | } catch ( Throwable $exception ) { 1927 | if ( true === $this->suppress_errors ) { 1928 | $result = false; 1929 | } else { 1930 | throw $exception; 1931 | } 1932 | } 1933 | 1934 | // Maybe log last used to heartbeats 1935 | if ( ! empty( $this->check_dbh_heartbeats ) ) { 1936 | 1937 | // Lookup name 1938 | $name = $this->lookup_dbhs_name( $dbh ); 1939 | 1940 | // Set last used for this dbh 1941 | if ( ! empty( $name ) ) { 1942 | $this->dbhname_heartbeats[ $name ]['last_used'] = microtime( true ); 1943 | } 1944 | } 1945 | 1946 | return $result; 1947 | } 1948 | 1949 | /** 1950 | * Closes the current database connection. 1951 | * 1952 | * @since 1.0.0 1953 | * 1954 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 1955 | * - the current database 1956 | * - the database housing the specified table 1957 | * - the database of the MySQL resource 1958 | * 1959 | * @return bool True if the connection was successfully closed. False if it wasn't 1960 | * or the connection doesn't exist. 1961 | */ 1962 | public function close( $dbh_or_table = false ) { 1963 | $dbh = $this->get_db_object( $dbh_or_table ); 1964 | 1965 | if ( ! $this->dbh_type_check( $dbh ) ) { 1966 | return false; 1967 | } 1968 | 1969 | $closed = mysqli_close( $dbh ); 1970 | 1971 | if ( ! empty( $closed ) ) { 1972 | $this->dbh = null; 1973 | } 1974 | 1975 | return $closed; 1976 | } 1977 | 1978 | /** 1979 | * Whether or not MySQL database is at least the required minimum version. 1980 | * The additional argument allows the caller to check a specific database. 1981 | * 1982 | * @since 1.0.0 1983 | * 1984 | * @global $wp_version 1985 | * @global $required_mysql_version 1986 | * 1987 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 1988 | * - the current database 1989 | * - the database housing the specified table 1990 | * - the database of the MySQL resource 1991 | * 1992 | * @return WP_Error 1993 | */ 1994 | public function check_database_version( $dbh_or_table = false ) { 1995 | global $wp_version, $required_mysql_version; 1996 | 1997 | // Make sure the server has the required MySQL version 1998 | $mysql_version = preg_replace( '|[^0-9\.]|', '', $this->db_version( $dbh_or_table ) ); 1999 | if ( version_compare( $mysql_version, $required_mysql_version, '<' ) ) { 2000 | // translators: 1. WordPress version, 2. MySql Version. 2001 | return new WP_Error( 'database_version', sprintf( __( 'ERROR: WordPress %1$s requires MySQL %2$s or higher', 'ludicrousdb' ), $wp_version, $required_mysql_version ) ); 2002 | } 2003 | } 2004 | 2005 | /** 2006 | * This function is called when WordPress is generating the table schema to 2007 | * determine whether or not the current database supports or needs the 2008 | * collation statements. 2009 | * 2010 | * The additional argument allows the caller to check a specific database. 2011 | * 2012 | * @since 1.0.0 2013 | * 2014 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 2015 | * - the current database 2016 | * - the database housing the specified table 2017 | * - the database of the MySQL resource 2018 | * 2019 | * @return bool 2020 | */ 2021 | public function supports_collation( $dbh_or_table = false ) { 2022 | _deprecated_function( __FUNCTION__, '3.5', 'wpdb::has_cap( \'collation\' )' ); 2023 | 2024 | return $this->has_cap( 'collation', $dbh_or_table ); 2025 | } 2026 | 2027 | /** 2028 | * Generic function to determine if a database supports a particular feature. 2029 | * The additional argument allows the caller to check a specific database. 2030 | * 2031 | * @since 1.0.0 2032 | * 2033 | * @param string $db_cap The feature. 2034 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 2035 | * - the current database 2036 | * - the database housing the specified table 2037 | * - the database of the MySQL resource 2038 | * 2039 | * @return bool 2040 | */ 2041 | public function has_cap( $db_cap, $dbh_or_table = false ) { 2042 | $db_version = $this->db_version( $dbh_or_table ); 2043 | $db_server_info = $this->db_server_info( $dbh_or_table ); 2044 | 2045 | // Account for MariaDB version being prefixed with '5.5.5-' on older PHP versions. 2046 | // See: https://github.com/Automattic/HyperDB/pull/143 2047 | if ( 2048 | ( '5.5.5' === $db_version ) 2049 | && 2050 | ( false !== strpos( $db_server_info, 'MariaDB' ) ) 2051 | && 2052 | version_compare( phpversion(), '8.0.16', '<' ) // PHP 8.0.15 or older. 2053 | ) { 2054 | // Strip the '5.5.5-' prefix and set the version to the correct value. 2055 | $db_server_info = preg_replace( '/^5\.5\.5-(.*)/', '$1', $db_server_info ); 2056 | $db_version = preg_replace( '/[^0-9.].*/', '', $db_server_info ); 2057 | } 2058 | 2059 | switch ( strtolower( $db_cap ) ) { 2060 | case 'collation': // @since WP 2.5.0 2061 | case 'group_concat': // @since WP 2.7.0 2062 | case 'subqueries': // @since WP 2.7.0 2063 | return version_compare( $db_version, '4.1', '>=' ); 2064 | case 'set_charset': 2065 | return version_compare( $db_version, '5.0.7', '>=' ); 2066 | case 'utf8mb4': // @since WP 4.1.0 2067 | if ( version_compare( $db_version, '5.5.3', '<' ) ) { 2068 | return false; 2069 | } 2070 | 2071 | $dbh = $this->get_db_object( $dbh_or_table ); 2072 | 2073 | if ( $this->dbh_type_check( $dbh ) ) { 2074 | $client_version = mysqli_get_client_info(); 2075 | 2076 | /* 2077 | * libmysql has supported utf8mb4 since 5.5.3, same as the MySQL server. 2078 | * mysqlnd has supported utf8mb4 since 5.0.9. 2079 | */ 2080 | if ( false !== strpos( $client_version, 'mysqlnd' ) ) { 2081 | $client_version = preg_replace( '/^\D+([\d.]+).*/', '$1', $client_version ); 2082 | 2083 | return version_compare( $client_version, '5.0.9', '>=' ); 2084 | } else { 2085 | return version_compare( $client_version, '5.5.3', '>=' ); 2086 | } 2087 | } 2088 | break; 2089 | case 'utf8mb4_520': // @since WP 4.6.0 2090 | return version_compare( $db_version, '5.6', '>=' ); 2091 | } 2092 | 2093 | return false; 2094 | } 2095 | 2096 | /** 2097 | * Retrieve the name of the function that called wpdb. 2098 | * 2099 | * Searches up the list of functions until it reaches 2100 | * the one that would most logically had called this method. 2101 | * 2102 | * @since 2.5.0 2103 | * 2104 | * @return string Comma separated list of the calling functions. 2105 | */ 2106 | public function get_caller() { 2107 | return ( true === $this->save_backtrace ) 2108 | ? wp_debug_backtrace_summary( __CLASS__ ) 2109 | : null; 2110 | } 2111 | 2112 | /** 2113 | * The database version number. 2114 | * 2115 | * @since 1.0.0 2116 | * 2117 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 2118 | * - the current database 2119 | * - the database housing the specified table 2120 | * - the database of the MySQL resource 2121 | * 2122 | * @return false|string False on failure. Version number on success. 2123 | */ 2124 | public function db_version( $dbh_or_table = false ) { 2125 | return preg_replace( '/[^0-9.].*/', '', $this->db_server_info( $dbh_or_table ) ); 2126 | } 2127 | 2128 | /** 2129 | * Retrieves full MySQL server information. 2130 | * 2131 | * @since 5.0.0 2132 | * 2133 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 2134 | * - the current database 2135 | * - the database housing the specified table 2136 | * - the database of the MySQL resource 2137 | * 2138 | * @return string|false Server info on success, false on failure. 2139 | */ 2140 | public function db_server_info( $dbh_or_table = false ) { 2141 | $dbh = $this->get_db_object( $dbh_or_table ); 2142 | 2143 | if ( ! $this->dbh_type_check( $dbh ) ) { 2144 | return false; 2145 | } 2146 | 2147 | $server_info = mysqli_get_server_info( $dbh ); 2148 | 2149 | return $server_info; 2150 | } 2151 | 2152 | /** 2153 | * Get the db connection object 2154 | * 2155 | * @since 1.0.0 2156 | * 2157 | * @param false|string|mysqli|resource $dbh_or_table Optional. The database. One of: 2158 | * - the current database 2159 | * - the database housing the specified table 2160 | * - the database of the MySQL resource 2161 | * @return bool|mysqli|resource 2162 | */ 2163 | private function get_db_object( $dbh_or_table = false ) { 2164 | 2165 | // No database 2166 | $dbh = false; 2167 | 2168 | // Database 2169 | if ( $this->dbh_type_check( $dbh_or_table ) ) { 2170 | $dbh = &$dbh_or_table; 2171 | 2172 | // Database 2173 | } elseif ( 2174 | ( false === $dbh_or_table ) 2175 | && 2176 | $this->dbh_type_check( $this->dbh ) 2177 | ) { 2178 | $dbh = &$this->dbh; 2179 | 2180 | // Table name 2181 | } elseif ( is_string( $dbh_or_table ) ) { 2182 | $dbh = $this->db_connect( "SELECT FROM {$dbh_or_table} {$this->users}" ); 2183 | } 2184 | 2185 | return $dbh; 2186 | } 2187 | 2188 | /** 2189 | * Check database object type. 2190 | * 2191 | * @since 1.0.0 2192 | * 2193 | * @param mysqli|resource $dbh Database resource. 2194 | * 2195 | * @return bool 2196 | */ 2197 | private function dbh_type_check( $dbh ) { 2198 | if ( $dbh instanceof mysqli ) { 2199 | return true; 2200 | } elseif ( is_resource( $dbh ) ) { 2201 | return true; 2202 | } 2203 | 2204 | return false; 2205 | } 2206 | 2207 | /** 2208 | * Logs query data. 2209 | * 2210 | * @since 4.3.0 2211 | * 2212 | * @param string $query The query's SQL. 2213 | * @param float $query_time Total time spent on the query, in seconds. 2214 | * @param string $query_callstack Comma separated list of the calling functions. 2215 | * @param float $query_start Unix timestamp of the time at the start of the query. 2216 | * @param array $query_data Custom query data. 2217 | * } 2218 | */ 2219 | public function log_query( $query = '', $query_time = 0, $query_callstack = '', $query_start = 0, $query_data = array() ) { 2220 | 2221 | /** 2222 | * Filters the custom query data being logged. 2223 | * 2224 | * Caution should be used when modifying any of this data, it is recommended that any additional 2225 | * information you need to store about a query be added as a new associative entry to the fourth 2226 | * element $query_data. 2227 | * 2228 | * @since 5.3.0 2229 | * 2230 | * @param array $query_data Custom query data. 2231 | * @param string $query The query's SQL. 2232 | * @param float $query_time Total time spent on the query, in seconds. 2233 | * @param string $query_callstack Comma separated list of the calling functions. 2234 | * @param float $query_start Unix timestamp of the time at the start of the query. 2235 | */ 2236 | $query_data = apply_filters( 'log_query_custom_data', $query_data, $query, $query_time, $query_callstack, $query_start ); 2237 | 2238 | // Pass to custom callback... 2239 | if ( is_callable( $this->save_query_callback ) ) { 2240 | $this->queries[] = call_user_func_array( 2241 | $this->save_query_callback, 2242 | array( 2243 | $query, 2244 | $query_time, 2245 | $query_callstack, 2246 | $query_start, 2247 | $query_data, 2248 | &$this, 2249 | ) 2250 | ); 2251 | 2252 | // ...or save it to the queries array 2253 | } else { 2254 | $this->queries[] = array( 2255 | $query, 2256 | $query_time, 2257 | $query_callstack, 2258 | $query_start, 2259 | $query_data, 2260 | ); 2261 | } 2262 | } 2263 | 2264 | /** 2265 | * Check the responsiveness of a TCP/IP daemon 2266 | * 2267 | * @since 1.0.0 2268 | * 2269 | * @param string $host Host. 2270 | * @param int $port Port or socket. 2271 | * @param float $float_timeout Timeout in seconds, as float number (). 2272 | * 2273 | * @return bool true when $host:$post responds within $float_timeout seconds, else false 2274 | */ 2275 | public function check_tcp_responsiveness( $host, $port, $float_timeout ) { 2276 | 2277 | // Get the cache key 2278 | $cache_key = $this->tcp_get_cache_key( $host, $port ); 2279 | 2280 | // Persistent cached value exists 2281 | $cached_value = $this->tcp_cache_get( $cache_key ); 2282 | 2283 | // Confirmed up or down response 2284 | if ( 'up' === $cached_value ) { 2285 | $this->tcp_responsive = true; 2286 | 2287 | return true; 2288 | } elseif ( 'down' === $cached_value ) { 2289 | $this->tcp_responsive = false; 2290 | 2291 | return false; 2292 | } 2293 | 2294 | // Defaults 2295 | $errno = 0; 2296 | $errstr = ''; 2297 | 2298 | // Try to get a new socket 2299 | // phpcs:disable 2300 | $socket = $this->is_debug() 2301 | ? fsockopen( $host, $port, $errno, $errstr, $float_timeout ) 2302 | : @fsockopen( $host, $port, $errno, $errstr, $float_timeout ); 2303 | // phpcs:enable 2304 | 2305 | // No socket 2306 | if ( false === $socket ) { 2307 | $this->tcp_cache_set( $cache_key, 'down' ); 2308 | 2309 | return "[ > {$float_timeout} ] ({$errno}) '{$errstr}'"; 2310 | } 2311 | 2312 | // Close the socket 2313 | // phpcs:ignore 2314 | fclose( $socket ); 2315 | 2316 | // Using API 2317 | $this->tcp_cache_set( $cache_key, 'up' ); 2318 | 2319 | return true; 2320 | } 2321 | 2322 | /** 2323 | * Run lag cache callbacks and return current lag 2324 | * 2325 | * @since 2.1.0 2326 | * 2327 | * @return int 2328 | */ 2329 | public function get_lag_cache() { 2330 | $this->lag = $this->run_callbacks( 'get_lag_cache' ); 2331 | 2332 | return $this->check_lag(); 2333 | } 2334 | 2335 | /** 2336 | * Run query log callbacks and return the return value. 2337 | * 2338 | * @since 5.2.0 2339 | * 2340 | * @param string $query The query's SQL. 2341 | * @param mixed $retval The return value of the query. 2342 | * 2343 | * @return void 2344 | */ 2345 | public function run_query_log_callbacks( $query = '', $retval = null ) { 2346 | 2347 | // Setup the callback data 2348 | $callback_data = array( 2349 | $query, 2350 | $retval, 2351 | $this->last_error, 2352 | ); 2353 | 2354 | // Run the callbacks 2355 | $this->run_callbacks( 'sql_query_log', $callback_data ); 2356 | } 2357 | 2358 | /** 2359 | * Should we try to ping the MySQL host? 2360 | * 2361 | * @since 4.1.0 2362 | * 2363 | * @param string $dbhname Database name. 2364 | * 2365 | * @return bool True if we should try to ping the MySQL host, false otherwise. 2366 | */ 2367 | public function should_mysql_ping( $dbhname = '' ) { 2368 | 2369 | // Return false if empty handle or checks are disabled 2370 | if ( 2371 | empty( $dbhname ) 2372 | || 2373 | empty( $this->check_dbh_heartbeats ) 2374 | ) { 2375 | return false; 2376 | } 2377 | 2378 | // Return true if no heartbeat yet 2379 | if ( empty( $this->dbhname_heartbeats[ $dbhname ] ) ) { 2380 | return true; 2381 | } 2382 | 2383 | // Return true if last error is a down server 2384 | if ( 2385 | ! empty( $this->dbhname_heartbeats[ $dbhname ]['last_errno'] ) 2386 | && 2387 | ( DB_SERVER_GONE_ERROR === $this->dbhname_heartbeats[ $dbhname ]['last_errno'] ) 2388 | ) { 2389 | 2390 | // Also clear the last error 2391 | unset( $this->dbhname_heartbeats[ $dbhname ]['last_errno'] ); 2392 | 2393 | return true; 2394 | } 2395 | 2396 | // Return true if last used is older than recheck timeout 2397 | if ( ( microtime( true ) - $this->dbhname_heartbeats[ $dbhname ]['last_used'] ) > $this->recheck_timeout ) { 2398 | return true; 2399 | } 2400 | 2401 | // Default to false 2402 | return false; 2403 | } 2404 | 2405 | /** 2406 | * Run lag callbacks and return current lag 2407 | * 2408 | * @since 2.1.0 2409 | * 2410 | * @return int 2411 | */ 2412 | public function get_lag() { 2413 | $this->lag = $this->run_callbacks( 'get_lag' ); 2414 | 2415 | return $this->check_lag(); 2416 | } 2417 | 2418 | /** 2419 | * Check lag 2420 | * 2421 | * @since 2.1.0 2422 | * 2423 | * @return int 2424 | */ 2425 | public function check_lag() { 2426 | if ( false === $this->lag ) { 2427 | return DB_LAG_UNKNOWN; 2428 | } 2429 | 2430 | if ( $this->lag > $this->lag_threshold ) { 2431 | return DB_LAG_BEHIND; 2432 | } 2433 | 2434 | return DB_LAG_OK; 2435 | } 2436 | 2437 | /** 2438 | * Retrieves the character set for a database table. 2439 | * 2440 | * NOTE: This must be called after LudicrousDB::db_connect, so 2441 | * that wpdb::dbh is set correctly. 2442 | * 2443 | * @param string $table Table name 2444 | * 2445 | * @return mixed The table character set, or WP_Error if we couldn't find it 2446 | */ 2447 | protected function get_table_charset( $table ) { 2448 | $tablekey = strtolower( $table ); 2449 | 2450 | /** 2451 | * Filter the table charset value before the DB is checked. 2452 | * 2453 | * Passing a non-null value to the filter will effectively short-circuit 2454 | * checking the DB for the charset, returning that value instead. 2455 | * 2456 | * @param string $charset The character set to use. Default null. 2457 | * @param string $table The name of the table being checked. 2458 | */ 2459 | $charset = apply_filters( 'pre_get_table_charset', null, $table ); 2460 | if ( null !== $charset ) { 2461 | return $charset; 2462 | } 2463 | 2464 | if ( isset( $this->table_charset[ $tablekey ] ) ) { 2465 | return $this->table_charset[ $tablekey ]; 2466 | } 2467 | 2468 | $charsets = $columns = array(); 2469 | 2470 | $table_parts = explode( '.', $table ); 2471 | $table = '`' . implode( '`.`', $table_parts ) . '`'; 2472 | $results = $this->get_results( "SHOW FULL COLUMNS FROM {$table}" ); 2473 | 2474 | if ( empty( $results ) ) { 2475 | return new WP_Error( 'wpdb_get_table_charset_failure' ); 2476 | } 2477 | 2478 | foreach ( $results as $column ) { 2479 | $columns[ strtolower( $column->Field ) ] = $column; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 2480 | } 2481 | 2482 | $this->col_meta[ $tablekey ] = $columns; 2483 | 2484 | foreach ( $columns as $column ) { 2485 | if ( ! empty( $column->Collation ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 2486 | list( $charset ) = explode( '_', $column->Collation ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 2487 | 2488 | // If the current connection can't support utf8mb4 characters, let's only send 3-byte utf8 characters. 2489 | if ( 2490 | ( 'utf8mb4' === $charset ) 2491 | && 2492 | ! $this->has_cap( 'utf8mb4' ) 2493 | ) { 2494 | $charset = 'utf8'; 2495 | } 2496 | 2497 | $charsets[ strtolower( $charset ) ] = true; 2498 | } 2499 | 2500 | list( $type ) = explode( '(', $column->Type ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 2501 | 2502 | // A binary/blob means the whole query gets treated like this. 2503 | if ( in_array( strtoupper( $type ), self::$bin_blobs, true ) ) { 2504 | $this->table_charset[ $tablekey ] = 'binary'; 2505 | 2506 | return 'binary'; 2507 | } 2508 | } 2509 | 2510 | // utf8mb3 is an alias for utf8. 2511 | if ( isset( $charsets['utf8mb3'] ) ) { 2512 | $charsets['utf8'] = true; 2513 | unset( $charsets['utf8mb3'] ); 2514 | } 2515 | 2516 | // Check if there is more than one charset in play. 2517 | $count = count( $charsets ); 2518 | if ( 1 === $count ) { 2519 | $charset = key( $charsets ); 2520 | 2521 | // No charsets, assume this table can store whatever. 2522 | } elseif ( 0 === $count ) { 2523 | $charset = false; 2524 | 2525 | // More than one charset. Remove latin1 if present and recalculate. 2526 | } else { 2527 | unset( $charsets['latin1'] ); 2528 | $count = count( $charsets ); 2529 | 2530 | // Only one charset (besides latin1). 2531 | if ( 1 === $count ) { 2532 | $charset = key( $charsets ); 2533 | 2534 | // Two charsets, but they're utf8 and utf8mb4, use utf8. 2535 | } elseif ( 2 === $count && isset( $charsets['utf8'], $charsets['utf8mb4'] ) ) { 2536 | $charset = 'utf8'; 2537 | 2538 | // Two mixed character sets. ascii. 2539 | } else { 2540 | $charset = 'ascii'; 2541 | } 2542 | } 2543 | 2544 | $this->table_charset[ $tablekey ] = $charset; 2545 | 2546 | return $charset; 2547 | } 2548 | 2549 | /** 2550 | * Given a string, a character set and a table, ask the DB to check the 2551 | * string encoding. Classes that extend wpdb can override this function 2552 | * without needing to copy/paste all of wpdb::strip_invalid_text(). 2553 | * 2554 | * NOTE: This must be called after LudicrousDB::db_connect, so 2555 | * that wpdb::dbh is set correctly. 2556 | * 2557 | * @since 1.0.0 2558 | * 2559 | * @param string $to_strip String to convert 2560 | * @param string $charset Character set to test against (uses MySQL character set names) 2561 | * 2562 | * @return mixed The converted string, or a WP_Error if the conversion fails 2563 | */ 2564 | protected function strip_invalid_text_using_db( $to_strip, $charset ) { 2565 | $sql = "SELECT CONVERT( %s USING {$charset} )"; 2566 | $query = $this->prepare( $sql, $to_strip ); 2567 | $result = $this->_do_query( $query, $this->dbh ); 2568 | 2569 | // Bail with error if no result 2570 | if ( empty( $result ) ) { 2571 | return new WP_Error( 'wpdb_convert_text_failure' ); 2572 | } 2573 | 2574 | // Fetch row 2575 | $row = mysqli_fetch_row( $result ); 2576 | 2577 | // Bail with error if no rows 2578 | if ( ! is_array( $row ) || count( $row ) < 1 ) { 2579 | return new WP_Error( 'wpdb_convert_text_failure' ); 2580 | } 2581 | 2582 | return $row[0]; 2583 | } 2584 | 2585 | /** TCP Cache *************************************************************/ 2586 | 2587 | /** 2588 | * Start the TCP cache 2589 | * 2590 | * Only runs once. Subsequent calls will bail early. 2591 | * 2592 | * @since 5.2.0 2593 | * 2594 | * @see https://github.com/stuttter/ludicrousdb/issues/126 2595 | * @uses wp_start_object_cache() If available, to start the object cache. 2596 | * @static var bool $started True if started. False if not. 2597 | */ 2598 | protected function tcp_cache_start() { 2599 | static $started = null; 2600 | 2601 | // Bail if added or caching not available yet 2602 | if ( true === $started ) { 2603 | return; 2604 | } 2605 | 2606 | // Maybe start object cache 2607 | if ( function_exists( 'wp_start_object_cache' ) ) { 2608 | wp_start_object_cache(); 2609 | 2610 | // Make sure the global group is added 2611 | $this->tcp_cache_add_global_group(); 2612 | } 2613 | 2614 | // Set started 2615 | $started = true; 2616 | } 2617 | 2618 | /** 2619 | * Add global TCP cache group. 2620 | * 2621 | * Only runs if object cache is available and the necessary WordPress 2622 | * function (wp_cache_add_global_groups) exists. 2623 | * 2624 | * @since 5.2.0 2625 | */ 2626 | protected function tcp_cache_add_global_group() { 2627 | 2628 | // Add the cache group 2629 | if ( function_exists( 'wp_cache_add_global_groups' ) ) { 2630 | wp_cache_add_global_groups( $this->tcp_cache_group ); 2631 | } 2632 | } 2633 | 2634 | /** 2635 | * Get the cache key used for TCP responses 2636 | * 2637 | * @since 3.0.0 2638 | * 2639 | * @param string $host Host 2640 | * @param string $port Port or socket. 2641 | * 2642 | * @return string 2643 | */ 2644 | protected function tcp_get_cache_key( $host, $port ) { 2645 | return "{$host}:{$port}"; 2646 | } 2647 | 2648 | /** 2649 | * Get the number of seconds TCP response is good for 2650 | * 2651 | * @since 3.0.0 2652 | * 2653 | * @return int 2654 | */ 2655 | protected function tcp_get_cache_expiration() { 2656 | return 10; 2657 | } 2658 | 2659 | /** 2660 | * Check if TCP is using a persistent cache or not. 2661 | * 2662 | * @since 5.1.0 2663 | * 2664 | * @return bool True if yes. False if no. 2665 | */ 2666 | protected function tcp_is_cache_persistent() { 2667 | 2668 | // Check if using external object cache 2669 | if ( wp_using_ext_object_cache() ) { 2670 | 2671 | // Cache is persistent 2672 | return true; 2673 | } 2674 | 2675 | // Cache is not persistent 2676 | return false; 2677 | } 2678 | 2679 | /** 2680 | * Get cached up/down value of previous TCP response. 2681 | * 2682 | * Falls back to local cache if persistent cache is not available. 2683 | * 2684 | * @since 3.0.0 2685 | * 2686 | * @param string $key Results of tcp_get_cache_key() 2687 | * 2688 | * @return mixed Results of wp_cache_get() 2689 | */ 2690 | protected function tcp_cache_get( $key = '' ) { 2691 | 2692 | // Bail for invalid key 2693 | if ( empty( $key ) ) { 2694 | return false; 2695 | } 2696 | 2697 | // Get from persistent cache 2698 | if ( $this->tcp_is_cache_persistent() ) { 2699 | return wp_cache_get( $key, $this->tcp_cache_group ); 2700 | 2701 | // Fallback to local cache 2702 | } elseif ( ! empty( $this->tcp_cache[ $key ] ) ) { 2703 | 2704 | // Not expired 2705 | if ( 2706 | ! empty( $this->tcp_cache[ $key ]['expiration'] ) 2707 | && 2708 | ( time() < $this->tcp_cache[ $key ]['expiration'] ) 2709 | ) { 2710 | 2711 | // Return value or false if empty 2712 | return ! empty( $this->tcp_cache[ $key ]['value'] ) 2713 | ? $this->tcp_cache[ $key ]['value'] 2714 | : false; 2715 | 2716 | // Expired, so delete and proceed 2717 | } else { 2718 | $this->tcp_cache_delete( $key ); 2719 | } 2720 | } 2721 | 2722 | return false; 2723 | } 2724 | 2725 | /** 2726 | * Set cached up/down value of current TCP response. 2727 | * 2728 | * Falls back to local cache if persistent cache is not available. 2729 | * 2730 | * @since 3.0.0 2731 | * 2732 | * @param string $key Results of tcp_get_cache_key() 2733 | * @param string $value "up" or "down" based on TCP response 2734 | * 2735 | * @return bool Results of wp_cache_set() or true 2736 | */ 2737 | protected function tcp_cache_set( $key = '', $value = '' ) { 2738 | 2739 | // Bail if invalid values were passed 2740 | if ( empty( $key ) || empty( $value ) ) { 2741 | return false; 2742 | } 2743 | 2744 | // Get expiration 2745 | $expires = $this->tcp_get_cache_expiration(); 2746 | 2747 | // Add to persistent cache 2748 | if ( $this->tcp_is_cache_persistent() ) { 2749 | return wp_cache_set( $key, $value, $this->tcp_cache_group, $expires ); 2750 | 2751 | // Fallback to local cache 2752 | } else { 2753 | $this->tcp_cache[ $key ] = array( 2754 | 'value' => $value, 2755 | 'expiration' => time() + $expires, 2756 | ); 2757 | } 2758 | 2759 | return true; 2760 | } 2761 | 2762 | /** 2763 | * Delete cached up/down value of current TCP response. 2764 | * 2765 | * Falls back to local cache if persistent cache is not available. 2766 | * 2767 | * @since 5.1.0 2768 | * 2769 | * @param string $key Results of tcp_get_cache_key() 2770 | * 2771 | * @return bool Results of wp_cache_delete() or true 2772 | */ 2773 | protected function tcp_cache_delete( $key = '' ) { 2774 | 2775 | // Bail if invalid key 2776 | if ( empty( $key ) ) { 2777 | return false; 2778 | } 2779 | 2780 | // Delete from persistent cache 2781 | if ( $this->tcp_is_cache_persistent() ) { 2782 | return wp_cache_delete( $key, $this->tcp_cache_group ); 2783 | 2784 | // Fallback to local cache 2785 | } else { 2786 | unset( $this->tcp_cache[ $key ] ); 2787 | } 2788 | 2789 | return true; 2790 | } 2791 | 2792 | /** 2793 | * Find a dbh name value for a given $dbh object. 2794 | * 2795 | * @since 5.0.0 2796 | * 2797 | * @param object $dbh The dbh object for which to find the dbh name 2798 | * 2799 | * @return string The dbh name 2800 | */ 2801 | private function lookup_dbhs_name( $dbh = false ) { 2802 | 2803 | // Loop through database hosts and look for this one 2804 | foreach ( $this->dbhs as $dbhname => $other_dbh ) { 2805 | 2806 | // Match found so return the key 2807 | if ( $dbh === $other_dbh ) { 2808 | return $dbhname; 2809 | } 2810 | } 2811 | 2812 | // No match 2813 | return false; 2814 | } 2815 | 2816 | /** Deprecated ************************************************************/ 2817 | 2818 | /** 2819 | * Set a flag to prevent reading from replicas which might be lagging after 2820 | * a write. 2821 | * 2822 | * @since 1.0.0 2823 | * @deprecated 5.2.0 Use send_reads_to_primaries() instead. 2824 | */ 2825 | public function send_reads_to_masters() { 2826 | _deprecated_function( __FUNCTION__, '5.2.0', 'wpdb::send_reads_to_primaries()' ); 2827 | $this->send_reads_to_primaries(); 2828 | } 2829 | } 2830 | -------------------------------------------------------------------------------- /ludicrousdb/includes/functions.php: -------------------------------------------------------------------------------- 1 | add_table( $dataset, $table ); 54 | } 55 | 56 | /** 57 | * Convenience function to add a database server. 58 | * 59 | * This is backwards-compatible with a procedural config style. 60 | * 61 | * lhost, part, and dc were removed from LudicrousDB because the read and write 62 | * parameters provide enough power to achieve the desired effects via config. 63 | * 64 | * @param string $dataset Dataset: The name of the dataset. Just use "global" if you don't need horizontal partitioning. 65 | * @param int $part Partition: The vertical partition number (1, 2, 3, etc.). Use "0" if you don't need vertical partitioning. 66 | * @param string $dc Datacenter: Where the database server is located. Airport codes are convenient. Use whatever. 67 | * @param int $read Read group: Tries all servers in lowest number group before trying higher number group. Typical: 1 for replicas, 2 for primary. This will cause reads to go to replicas. 68 | * @param bool $write Write flag: Is this server writable? Works the same as $read. Typical: 1 for primary, 0 for replicas. 69 | * @param string $host Internet address: host:port of server on Internet. 70 | * @param string $lhost Local address: host:port of server for use when in same datacenter. Leave empty if no local address exists. 71 | * @param string $name Database name. 72 | * @param string $user Database user. 73 | * @param string $password Database password. 74 | * @param int $timeout Timeout. 75 | */ 76 | function ldb_add_db_server( $dataset, $part, $dc, $read, $write, $host, $lhost, $name, $user, $password, $timeout = 0.2 ) { 77 | 78 | // dc is not used in LudicrousDB. This produces the desired effect of 79 | // trying to connect to local servers before remote servers. Also 80 | // increases time allowed for TCP responsiveness check. 81 | if ( ! empty( $dc ) && ( defined( 'DATACENTER' ) && ( DATACENTER !== $dc ) ) ) { 82 | $read += 10000; 83 | $write += 10000; 84 | $timeout = 0.7; 85 | } 86 | 87 | // Maybe add part to dataset 88 | if ( ! empty( $part ) ) { 89 | $dataset = "{$dataset}_{$part}"; 90 | } 91 | 92 | // Put variables into array 93 | $database = compact( 'dataset', 'read', 'write', 'host', 'name', 'user', 'password', 'timeout' ); 94 | 95 | // Add database array to main object 96 | $GLOBALS['wpdb']->add_database( $database ); 97 | 98 | // Current datacenter 99 | if ( defined( 'DATACENTER' ) && ( DATACENTER === $dc ) ) { 100 | 101 | // lhost is not used in LudicrousDB. This configures LudicrousDB with an 102 | // additional server to represent the local hostname so it tries to 103 | // connect over the private interface before the public one. 104 | if ( ! empty( $lhost ) ) { 105 | 106 | $database['host'] = $lhost; 107 | 108 | if ( ! empty( $read ) ) { 109 | $database['read'] = $read - 0.5; 110 | } 111 | 112 | if ( ! empty( $write ) ) { 113 | $database['write'] = $write - 0.5; 114 | } 115 | 116 | $GLOBALS['wpdb']->add_database( $database ); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === LudicrousDB === 2 | Contributors: johnjamesjacoby, spacedmonkey 3 | Author: Triple J Software, Inc. 4 | Author URI: https://jjj.software 5 | Plugin URI: https://github.com/stuttter/ludicrousdb/ 6 | License URI: https://www.gnu.org/licenses/gpl-2.0.html 7 | License: GPLv2 or later 8 | Tags: database, mysql, performance, scaling, wpdb 9 | Requires PHP: 7.0 10 | Requires at least: 5.0 11 | Tested up to: 6.5 12 | Stable tag: 5.2.0 13 | 14 | LudicrousDB is an advanced database interface for WordPress that supports replication, fail-over, load balancing, and partitioning 15 | 16 | == Description == 17 | 18 | LudicrousDB is an advanced database interface for WordPress that replaces much of its built-in database functionality. 19 | 20 | The main differences are: 21 | 22 | * Connects to an arbitrary number of database servers 23 | * Inspects each query to determine the appropriate database 24 | * Designed specifically for large, multi-site, high-availability environments 25 | 26 | It supports: 27 | 28 | * Read and write servers (replication) 29 | * Configurable priority for reading and writing 30 | * Local and remote data-centers 31 | * Private and public networks 32 | * Different tables on different databases/hosts 33 | * Smart post-write primary reads 34 | * Fail-over for downed host 35 | * Advanced statistics for profiling 36 | 37 | It is based on the code currently used in production on WordPress.com & WordPress.org, with many database servers spanning multiple data-centers. 38 | 39 | It is a fork of Automattic's HyperDB. 40 | 41 | = Previous Contributors = 42 | 43 | HyperDB's original contributors: matt, andy, ryan, mdawaffe, vnsavage, automattic 44 | 45 | == Installation == 46 | 47 | See https://github.com/stuttter/ludicrousdb/wiki/0.-Installation 48 | 49 | == Frequently Asked Questions == 50 | 51 | = What can I do with LudicrousDB that I can't do with WPDB? = 52 | 53 | WordPress.org, the most complex LudicrousDB installation, manages millions of tables spanning thousands of databases. Dynamic configuration logic allows LudicrousDB to compute the location of tables by querying a central database. Custom scripts constantly balance database server resources by migrating tables and updating their locations in the central database. 54 | 55 | Stretch your imagination. You could create a dynamic configuration using persistent caching to gather intelligence about the state of the network. The only constant is the name of the configuration file. The rest, as they say, is PHP. 56 | 57 | = How does LudicrousDB support replication? = 58 | 59 | LudicrousDB does not provide replication services. That is done by configuring MySQL servers for replication. LudicrousDB can then be configured to use these servers appropriately, e.g. by connecting to primary servers to perform write queries. 60 | 61 | = How does LudicrousDB support load balancing? = 62 | 63 | LudicrousDB randomly selects database connections from priority groups that you configure. The most advantageous connections are tried first. Thus you can optimize your configuration for network topology, hardware capability, or any other scheme you invent. 64 | 65 | = How does LudicrousDB support failover? = 66 | 67 | Failover describes how LudicrousDB deals with connection failures. When LudicrousDB fails to connect to one database, it tries to connect to another database that holds the same data. If replication hasn't been set up, LudicrousDB tries reconnecting a few times before giving up. 68 | 69 | = How does LudicrousDB support partitioning? = 70 | 71 | LudicrousDB allows tables to be placed in arbitrary databases. It can use callbacks you write to compute the appropriate database for a given query. Thus you can partition your site's data according to your own scheme and configure LudicrousDB accordingly. 72 | 73 | = Is there any advantage to using LudicrousDB with just one database server? = 74 | 75 | None that has been measured. LudicrousDB does at least try again before giving up connecting, so it might help in cases where the web server is momentarily unable to connect to the database server. 76 | 77 | One way LudicrousDB differs from WPDB is that LudicrousDB does not attempt to connect to a database until a query is made. Thus a site with sufficiently aggressive persistent caching could remain read-only accessible despite the database becoming unreachable. 78 | 79 | = What if all database servers for a dataset go down? = 80 | 81 | Since LudicrousDB attempts a connection only when a query is made, your WordPress installation will not kill the site with a database error, but will let the code decide what to do next on an unsuccessful query. If you want to do something different, like setting a custom error page or kill the site, you need to define the 'db_connection_error' callback in your db-config.php. 82 | 83 | == Changelog == 84 | 85 | = 5.2.0 = 86 | * PHP 8.3 compatibility 87 | * Update default collation to utf8mb4_unicode_520_ci 88 | * Fix a few PHP warnings under some configurations 89 | 90 | = 5.0.0 = 91 | * PHP 7.3 compatibility 92 | * Update default collation to unicode_520_ci 93 | * Global cache group, and add caching to TCP connection statuses 94 | * Fix a few PHP warnings under some configurations 95 | * Fix bug causing timeout for ping under some configurations 96 | 97 | = 4.1.0 = 98 | * Fix WordPress 4.8.3 SQLi vulnerability 99 | 100 | = 4.0.0 = 101 | * Support for custom mu / plugin paths 102 | * Improved WordPress 4.6 capability 103 | * New filter - pre_query 104 | * New action - queried 105 | * Allow db-config.php file to stored in wp-content directory. 106 | 107 | = 3.0.0 = 108 | * Improved support for mu-plugins installation location 109 | * Remove APC support for TCP responses (use core caching functions instead) 110 | * Fix typos in documentation 111 | 112 | = 2.1.0 = 113 | * Fixed wrong return value for `CREATE`, `ALTER`, `TRUNCATE` and `DROP` queries 114 | * Merge improvements from WordPress core WPDB 115 | * Undefined variable fixes 116 | 117 | = 2.0.0 = 118 | * Fork from HyperDB 119 | * Include utf8mb4 support (for WordPress 4.2 compatibility) 120 | * Remove support for WPDB_PATH as require_wp_db() prevents it 121 | 122 | = 1.1.0 = 123 | * Extended callbacks functionality 124 | * Added connection error callback 125 | * Added replication lag detection support 126 | 127 | = 1.0.0 = 128 | * Removed support for WPMU and BackPress. 129 | * New class with inheritance: hyperdb extends wpdb. 130 | * New instantiation scheme: $wpdb = new hyperdb(); then include config. No more $db_* globals. 131 | * New configuration file name (db-config.php) and logic for locating it. (ABSPATH, dirname(ABSPATH), or pre-defined) 132 | * Added fallback to wpdb in case database config not found. 133 | * Renamed servers to databases in config in an attempt to reduce ambiguity. 134 | * Added config interface functions to hyperdb: add_database, add_table, add_callback. 135 | * Refactored db_server array to simplify finding a server. 136 | * Removed native support for datacenters and partitions. The same effects are accomplished by read/write parameters and dataset names. 137 | * Removed preg pattern support from $db_tables. Use callbacks instead. 138 | * Removed delay between connection retries and avoid immediate retry of same server when others are available to try. 139 | * Added connection stats. 140 | * Added save_query_callback for custom debug logging. 141 | * Refined SRTM granularity. Now only send reads to primaries when the written table is involved. 142 | * Improved connection reuse logic and added mysql_ping to recover from "server has gone away". 143 | * Added min_tries to configure the minimum number of connection attempts before bailing. 144 | * Added WPDB_PATH constant. Define this if you'd rather not use ABSPATH . WPINC . '/wp-db.php'. 145 | --------------------------------------------------------------------------------